A minimal implementation of a distance field ray marching in pico 8. Has color dithering and soft shadows.
--math
local function length(x, y, z) return sqrt(x*x + y*y + z*z) end
local function norm(x, y, z) local l = length(x,y,z) return x/l, y/l, z/l end
local function dot(xa, ya, za, xb, yb, zb) return xa*xb + ya*yb + za*zb end
--globals
local ex, ey, ez = 0, 1, -1.5 --camera position
local fov = 90 --camera FOV
local tmin, tmax = .1, 100 --minimum and maximum distance from camera
local maxSteps = 45 --maximum number of steps to take
local lx, ly, lz = norm(.2, .5, -.6) --light direction
--distance field functions
local function plane(x, y, z) return y end
local function sphere(x, y, z, radius)
local manhattanDistance = x + y + z
--this if is to avoid errors due to pico's limited number range
if manhattanDistance > radius * 2 then return manhattanDistance end
return length(x, y, z) - radius
end
--union combines two distance functions
local function union(a, am, b, bm) if a < b then return a, am else return b, bm end end
--scene defines the total distance function
local function scene(x, y, z)
local d, m = tmax, 0 --max distance is sky
d, m = union(d, m, plane(x, y, z), "green")
d, m = union(d, m, sphere(x, y-1, z, .5), "red")
return d,m
end
--calculates the normal at xyz
local function sceneNormal(x, y, z)
local eps = 0.1
local xa, xb = scene(x+eps, y, z), scene(x-eps ,y ,z)
local ya, yb = scene(x, y+eps, z), scene(x, y-eps ,z)
local za ,zb = scene(x, y, z+eps), scene(x, y, z-eps)
return norm(xa-xb, ya-yb, za-zb)
end
--rendering
local colorGradients = {
red = {1, 2, 8, 9, 10, 7},
green = {0, 1, 5, 3, 11},
sky = {15, 12, 12, 1},
}
--returns smooth color based on position and gradient
local function dither(x, y, gradient, value)
local whole = flr(value * #gradient)
local fraction = value * #gradient - whole
local low = gradient[min(whole + 1, #gradient)]
local high = gradient[min(min(whole + 1, #gradient) + 1, #gradient)]
if fraction < 1/7 then return low end
if fraction < 2/7 then if (x+1)%3==0 and y%3==0 then return high else return low end end
if fraction < 3/7 then if x%2==0 and y%2==0 then return high else return low end end
if fraction < 4/7 then if (x%2==0 and y%2==0) or (x%2~=0 and y%2~=0) then return high else return low end end
if fraction < 5/7 then if x%2==0 and (y+1)%2==0 then return low else return high end end
if fraction < 6/7 then if (x+1)%3==0 and y%3==0 then return low else return high end end
return high
end
local function getShadowPoint(t, x, y, z) return x + lx * t, y + ly * t, z + lz * t end
--computes the shoft shadow value
local function shadow(x,y,z)
--t starts at 0.2 so the shadow ray doesn't intersect
--the object it's trying to shadow
local res, t, distance, sx, sy, sz = 1, 0.2, 0, 0, 0, 0
for i = 1, 6 do
sx, sy, sz = getShadowPoint(t, x, y, z)
distance, _ = scene(sx, sy, sz) --we don't care about the color
res = min(res, 2 * distance / t) --increase 2 to get sharper shadows
t += min(max(distance, .02), .2)
if distance < .05 or t > 10.0 then break end
end
return min(max(res, 0), 1)
end
--calculates the final lighting and color
local function render(x, y, t, tx, ty, tz, rx, ry, rz, color)
local nx, ny, nz = sceneNormal(tx, ty, tz)
local light = 0
light += min(max(dot(nx, ny, nz, lx, ly, lz), 0), 1) --sun light
light *= shadow(tx,ty,tz) --shadow color
light = min(max(light, 0), 1) --clamp final light value
return dither(x, y, colorGradients[color], light)
end
--calculates the sky color
local function sky(x, y, rx, ry, rz)
local altitude = (min(max(ry, 0), 1) ^ 1.5)
return dither(x, y, colorGradients.sky, altitude)
end
--tracing
--this is the heart of a ray tracer
--the for loop pushes the test point forward until
--it finds a surface that is close enough to render
--the forward direction is based off the xy of the screen and fov
local function getRayDirection(x, y) return norm(x / 64 - 1, (128 - y) / 64 - 1, 90 / fov) end
local function getTestPoint(t, rx, ry, rz) return ex + rx * t, ey + ry * t, ez + rz * t end
function trace(x,y)
local rx, ry, rz = getRayDirection(x, y)
local tx, ty, tz = 0, 0, 0
local t, distance, color = 0, 0, 0
for i = 1, maxSteps do
tx, ty, tz = getTestPoint(t, rx, ry, rz)
distance, color = scene(tx, ty, tz)
--the test point is close enough, render
if distance < .05 then return render(x, y, t, tx, ty, tz, rx, ry, rz, color) end
--the test point is too far, give up, draw the sky
if distance >= tmax then break end
--move forward by some fraction
t += distance * .7
end
return sky(x, y, rx, ry, rz)
end
--just here to get pico to work the way it should be defaut tbh
function _init() cls() end
function _update() end
--pick random points to trace, but only if they
--havent been traced before
--cache the expensive trace function, and set pixel
local traced = {}
function _draw()
for s = 1, 400 do
local x, y = flr(rnd(128)), flr(rnd(128))
local i = x + y * 128
if not traced[i] then
pset(x, y, trace(x,y))
traced[i] = true
end
end
end