If you arrived here wondering, โwhat is raycastingโ then I invite you to scrub the field of view interval below to โlook aroundโ.
If you see a barchart, then you are correct and very precise. If you see a primitive image of a 3d blocky world (and yes some funny color switches), then you are also correct.
Raycasting is a rendering method used in many iconic and memorable computer games. This implementation - calculated with calculang and rendered to a bar chart, might be one the most naive of all raycasting ever casted.
(or can I say the most retro? :)
Letโs โseeโ how it works!
๐
The image is generated from:
a 2d representation of a world, aka a โlevelโ: a 64x64 grid describing walls and open space
a player position and a โfield of viewโ: together these determine the observable world
imaginary โraysโ which are โcastedโ from the player into the observable world
Those rays are casted until they hit a wall. (They will always eventually hit a wall as long as the level is closed by walls and the player stays inside it!)
Then to make the rendering above, we need to calculate the distance each ray travels before hitting a wall. Finally we take the inverse, thatโs the easy but no less important piece of the puzzle. The inverse makes rays that travel far get small bars (appearing further away), while rays that are stopped by a wall at short distance get bigger bars (appearing closer!) ๐
So the work in the method is really all about projecting rays (casting) and calculating distances to walls! ๐งฑ๐ซ๐
๐ค Wouldnโt we expect funny things to happen if the player goes inside a wall?
Well yes, yes we would. And below you can try it if you like! ๐พ
Where do we even begin to โproject raysโ and โcalculate distancesโ?
Above we have the level: this is a 2d representation of the world we will raycast - all 64x64 squares divided into empty space and walls.
The dot in the middle is the player, and is also the point from which rays are casted into the observable world. You can move the player using the controls above; both visuals will update to reflect player and field of view movements.
Tick this checkbox to continue and add a field of view visual cue:
Code
viewof observable_world_v = Inputs.checkbox(["overlay field of view"])observable_world = observable_world_v.lengthmd`${observable_world ?`\\o/### casting rays and ๐๐**Now you can cast rays by hovering or clicking in either visual above!** ๐ซ๐งฑ<br>*Rays are casted in the level view.*Some actual calculations for the ray appear below, but it is much more useful to **scan and compare visually**. Notice that **walls that are near to the player in the level view get large bars which appear closer in the image**, **while walls that are far from the player get small bars which appear further away** - this is all that raycasting does to create playable worlds!There is one other effect included in the renderings I use here: the **opacity** of bars similarly correspond to ray distances, resulting in a fog-like effect.It's a combination of many simple tricks like these that bring computer games to life.`:'<br><br><br>*check the box to continue!*'}`
appendix workings: active ray distance travelled calculation
Code
viewof ray_angle_in = Inputs.range([-4,4], {step:0.01,label:'angle for active ray'})md`The **active ray distance travelled** is calculated as **${main.ray_length({player_x_in,player_y_in,ray_angle_in})} blocks**. ${ray_angle_in <= fov2[1] && ray_angle_in >= fov2[0] ?'':'(outside FOV)'} (30-step projection below)`
Code
md`Its **inverse**, 1 / ${main.ray_length({player_x_in,player_y_in,ray_angle_in})}, is ~ ${Math.round(100*main.inverse_ray_length({player_x_in,player_y_in,ray_angle_in}))/100}. This is the value used in the bar chart for this ray angle.`
Code
md`Color identifier associated with the wall the ray hits is ${main.ray_hit_color({player_x_in,player_y_in,ray_angle_in})}.`
Code
md`The **active ray distance travelled** is derived from a projection (or 'casting') *from the player in the direction of the active ray angle*. We check the level along this projection and the distance given above is the number of steps before hitting a non-zero value (i.e. the ray hits a wall), see ray_value:`
For calculang devtools adjust entrypoint in devtools.
Code
scene.addSignalListener("ray_angle_in", (_, r) => {if (observable_world ==0) return; viewof ray_angle_in.value=Math.round(r.ray_angle_in*100)/100; ray_angle_in.dispatchEvent(newCustomEvent('input'), {bubbles:true});// I might never understand how to make this always work})
Code
level.addSignalListener("xy", (_, r) => { // this didn't improve perf a lot vs event listener: nearly every mousemove is a signal change, I supposeif (observable_world ==0) return; viewof ray_angle_in.value=Math.round(Math.atan2((r.level_y_in[0] - player_y_in) , (r.level_x_in[0] - player_x_in))*100)/100; ray_angle_in.dispatchEvent(newCustomEvent('input'), {bubbles:true});})
Source Code
---title: "some raycasting"order: 1author: - name: "Declan Naughton ๐งฎ๐จโ๐ป" url: "https://calcwithdec.dev/about.html"description: "some raycasting"format: html: echo: false resources: - '../../models/raycasting/*.js' - '../../models/raycasting/*.js.map' - '../../models/raycasting/*.json'---# Raycasting engine ๐ซ with calculangIf you arrived here wondering, "what is raycasting" then I invite you to scrub the field of view interval below to 'look around'.*If you see a barchart*, then you are correct and very precise.<br>*If you see a primitive **image of a 3d blocky world*** (and yes some funny color switches), then you are also correct.```{ojs}import {interval} from 'd/ef56a03cf2168c18'viewof fov = interval([-4, 4], { step: 0.005, value: [-1,1], label: 'field of view', width:400})embed( calcuvizspec({ models: [main], input_cursors: [{player_x_in:32,player_y_in:32 /* fixing for better perf below */}], mark: {type:'bar', point:false, clip:true}, encodings: { //row: {name: 'formula', type:'nominal', domain: ['inverse_ray_length','negative_inverse_ray_length']}, x: {grid: false, name: 'ray_angle_in', /*sort:'descending',*/type: 'quantitative', domain: _.range(fov[0],fov[1],ray_angle_in_step_size), nice:false, ticks:2}, color: {name: 'ray_hit_color', type:'nominal', legend:false}, y: {grid: false, name: 'inverse_ray_length', type: 'quantitative'}, opacity: {name: 'inverse_ray_length', type: 'quantitative', legend:false}, }, width: 500, height:200, spec_post_process: spec => { spec.encoding.y.scale = {domain:[0,0.3],}; spec.encoding.x.axis = {tickCount:2, grid:false} return spec }}), {config: {background:'#f9f5f8'}})```**Raycasting is a rendering method used in many iconic and memorable computer games.** This implementation - calculated with [calculang](https://github.com/calculang/calculang) and rendered to a bar chart, might be one the most **naive** of all raycasting ever casted.<small>(or can I say the most *retro*? :)</small>Let's "see" how it works!## ๐The image is generated from:1. a 2d representation of a world, aka a '**level**': a 64x64 grid describing walls and open space1. a player **position** and a '**field of view**': together these determine the **observable world**1. imaginary '**rays**' which are **'casted' from the player into the observable world**Those rays are *casted until they hit a wall*. <small>(They will always eventually hit a wall as long as the level is closed by walls and the player stays inside it!)</small>Then to make the rendering above, we need to calculate the distance each ray travels before hitting a wall. Finally we take the **inverse**, that's the easy but no less important piece of the puzzle. *The inverse makes rays that travel far get small bars (appearing further away), while rays that are stopped by a wall at short distance get bigger bars (appearing closer!)* ๐So the work in the method is really all about **projecting rays** (casting) and **calculating distances to walls**! ๐งฑ๐ซ๐<!-- if you can't make it, make it a feature :) -->::: {.callout-important collapse="true" title="๐ค Wouldn't we expect funny things to happen if the player goes *inside a wall*?"}Well yes, yes we would. And below you can try it if you like! ๐พ:::Where do we even **begin** to "project rays" and "calculate distances"?*We do it all with **the level***.<!--:::{.column-screen}-->::: {layout-ncol=2}::: {.level}## the level โก```{ojs}viewof level = embed( calcuvizspec({ models: [main], input_cursors: [{player_x_in,player_y_in,ray_angle_in: clamped_ray_angle_in/*:(fov[0]+fov[1])/2*/ ,fov_in:fov2}], mark: {type:'rect',tooltip:false}, encodings: { x: {grid: false, name: 'level_x_in', type:'nominal', domain: _.range(0,63.1,1)}, y: {grid: false, name: 'level_y_in', type: 'nominal', domain: _.range(0,63.1,1)}, color: {name: (observable_world ? 'level_player_ray_fov' : 'level_player'), type: 'quantitative', legend:false}, //row: {name: 'formula', type:'nominal', domain: ['level', 'level_plus_player', 'level_plus_player_plus_ray']}, }, width: 300, height:300, spec_post_process: spec => { spec.params = [{"name": "xy", "select": { "type": "point", "on": "mouseover", "nearest": true, "encodings": ["x", "y"], toggle:false}}]; let a = spec.encoding.x.axis; spec.encoding.x.axis = {...a, ticks:false, labels:false} a = spec.encoding.y.axis; spec.encoding.y.axis = {...a, ticks:false, labels:false} spec.encoding.color.scale = {scheme: 'turbo'} return spec; }}), {actions:false})viewof player_x_in = Inputs.range([3,61], {step:1, value:32, label:'player x'})viewof player_y_in = Inputs.range([3,61], {step:1, value:32, label:'player y'})```:::::: {.scene}## the image ๐```{ojs}clamped_ray_angle_in = { if (ray_angle_in <= fov2[1] && ray_angle_in >= fov2[0]) return ray_angle_in else return undefined }//_.clamp(ray_angle_in,fov2[0],fov2[1])viewof scene = embed( calcuvizspec({ models: [main], input_cursors: [{player_x_in,player_y_in}], mark: {type:'bar', point:false, clip:true, tooltip:false}, encodings: { x: {grid: false, name: 'ray_angle_in', /*sort:'descending',*/type: 'quantitative', domain: [clamped_ray_angle_in, ..._.range(fov2[0],fov2[1],ray_angle_in_step_size)], nice:false, ticks:2}, color: {name: 'ray_hit_color', type:'nominal', legend:false}, y: {grid: false, name: 'inverse_ray_length', type: 'quantitative'}, opacity: {name: 'inverse_ray_length', type: 'quantitative', legend:false}, }, width: 500, height:300, spec_post_process: spec => { spec.encoding.y.scale = {domain:[0,0.3],}; spec.encoding.x.axis = {tickCount:2, grid:false} spec.params = [{"name": "ray_angle_in", "value": { "ray_angle_in": 0}, "select": { "type": "point", "on": "mouseover", "nearest": true, "encodings": ["x"], toggle:false}}]; spec.encoding.color.condition = {"test": `datum.ray_angle_in==${clamped_ray_angle_in || 999}`,"value": "red"} spec.encoding.opacity.condition = {"test": `datum.ray_angle_in==${clamped_ray_angle_in || 999}`,"value": 1} //spec.encoding.color.condition = {"param": "ray_angle_in", "empty":false, "value": "yellow"} // condition opacity also? return spec }}), {config: {/*background:'#f9fef8'*/}})viewof fov2 = interval([-5,5], { step: 0.005, value: [-1,1], label: 'field of view', width:400})```::::::Above we have the level: this is a 2d representation of the world we will raycast - all 64x64 squares divided into empty space and walls.**The dot in the middle is the player**, and is also the point from which rays are casted into the observable world. *You can move the player using the controls above*; both visuals will update to reflect player and field of view movements.**Tick this checkbox to continue and add a field of view visual cue:**```{ojs}viewof observable_world_v = Inputs.checkbox(["overlay field of view"])observable_world = observable_world_v.lengthmd`${observable_world ? `\\o/### casting rays and ๐๐**Now you can cast rays by hovering or clicking in either visual above!** ๐ซ๐งฑ<br>*Rays are casted in the level view.*Some actual calculations for the ray appear below, but it is much more useful to **scan and compare visually**. Notice that **walls that are near to the player in the level view get large bars which appear closer in the image**, **while walls that are far from the player get small bars which appear further away** - this is all that raycasting does to create playable worlds!There is one other effect included in the renderings I use here: the **opacity** of bars similarly correspond to ray distances, resulting in a fog-like effect.It's a combination of many simple tricks like these that bring computer games to life.` : '<br><br><br>*check the box to continue!*'}````<!-- ::: -->---### appendix workings: active ray distance travelled calculation```{ojs}viewof ray_angle_in = Inputs.range([-4,4], {step:0.01, label:'angle for active ray'})md`The **active ray distance travelled** is calculated as **${main.ray_length({player_x_in,player_y_in,ray_angle_in})} blocks**. ${ray_angle_in <= fov2[1] && ray_angle_in >= fov2[0] ? '' : '(outside FOV)'} (30-step projection below)`md`Its **inverse**, 1 / ${main.ray_length({player_x_in,player_y_in,ray_angle_in})}, is ~ ${Math.round(100*main.inverse_ray_length({player_x_in,player_y_in,ray_angle_in}))/100}. This is the value used in the bar chart for this ray angle.`md`Color identifier associated with the wall the ray hits is ${main.ray_hit_color({player_x_in,player_y_in,ray_angle_in})}.`md`The **active ray distance travelled** is derived from a projection (or 'casting') *from the player in the direction of the active ray angle*. We check the level along this projection and the distance given above is the number of steps before hitting a non-zero value (i.e. the ray hits a wall), see ray_value:`embed( calcuvizspec({ models: [main], input_cursors: [{player_x_in,player_y_in,ray_angle_in}], mark: 'text', encodings: { y: {name: 'formula', type:'nominal', domain: ['ray_x','ray_y','ray_value']}, x: {name: 'ray_steps_in', type: 'quantitative', domain: _.range(0,30,1)}, color: {name: 'formula', type:'nominal', domain: ['ray_x','ray_y','ray_value'], legend: false}, text: {name: 'value', type: 'quantitative', format:',.0f'}, }, width: 500, height:70}))```#### resolutionThe following determines resolution in bar charts. For increased performance, it can be useful to increase it.```{ojs}viewof ray_angle_in_step_size = Inputs.select([0.01,0.02,0.04], {value:0.02, label: 'ray_angle_in steps'})```## credits*This blog was made with [calculang](https://github.com/calculang), a language for calculations for **transparency, education and certainty** about **numbers and maths, and maths art*** ๐จ::: {.callout-tip}You can find the calculang model source code by opening Developer Tools (Ctrl+Shift+I) and navigating to the `.cul.js` file (Ctrl+P and search `.cul.js`).:::```{ojs}import { calcuvizspec } from "@declann/little-calcu-helpers"embed = require('vega-embed');viewof entrypoint = Inputs.select(['models/raycasting/raycasting.cul.js'], {label:'entrypoint'})entrypoint_no_cul_js = entrypoint.slice(0,-7)main = require(`../../${entrypoint_no_cul_js}.js`);introspection_fetch = await fetch(`../../${entrypoint_no_cul_js}.introspection.json`)introspection = introspection_fetch.json({typed:true})inputs = Object.values(introspection.cul_functions).filter(d => d.reason == 'input definition').map(d => d.name).sort()formulae = Object.values(introspection.cul_functions).filter(d => d.reason == 'definition').map(d => d.name)// formulae excluding pure inputsformulae_not_inputs = Object.values(introspection.cul_functions).filter(d => d.reason == 'definition' && inputs.indexOf(d.name+'_in') == -1).map(d => d.name)```::: {.callout-tip}For calculang devtools adjust *entrypoint* in [devtools](/pages/some-devtools.qmd).:::```{ojs}// COORDINATED VIEWS// performance on hover would be far far better if I vega changeset & signal update on ray_angle changes// & only when != existing signal value (or no good); currently no time for this// first step would = detaching variable names// ray angle coordinationscene.addSignalListener("ray_angle_in", (_, r) => { if (observable_world == 0) return; viewof ray_angle_in.value = Math.round(r.ray_angle_in*100)/100; ray_angle_in.dispatchEvent(new CustomEvent('input'), {bubbles:true}); // I might never understand how to make this always work})level.addSignalListener("xy", (_, r) => { // this didn't improve perf a lot vs event listener: nearly every mousemove is a signal change, I suppose if (observable_world == 0) return; viewof ray_angle_in.value = Math.round(Math.atan2((r.level_y_in[0] - player_y_in) , (r.level_x_in[0] - player_x_in))*100)/100; ray_angle_in.dispatchEvent(new CustomEvent('input'), {bubbles:true});})```