graphics

the rendering math i look up mid-implementation. wolf3d is raycasting (grid DDA), doom is BSP (2.5-D), true raytracing is its own section --; all three below.

jump to: pipeline · projection · rasterization · raycasting · bsp / doom · raytracing

pipeline & spaces

  • model →[M] world →[V] eye →[P] clip →(÷w) NDC →[viewport] screen
  • clip = P·V·M·(x,y,z,1)ᵀ; NDC = clip.xyz / clip.w
  • viewport: xₛ = (½·ndc.x + ½)·W; yₛ = (1 − (½·ndc.y + ½))·H (flip y)
  • conventions to pin down (where the bugs live): row- vs column-major, pre- vs post-multiply, LH vs RH, and clip-z range — [−1,1] (GL) vs [0,1] (D3D/VK/Metal)

projection

perspective, right-handed, clip z ∈ [−1,1]; c = cot(fovy/2), a = aspect, near n, far f:

[ c/a   0    0              0            ]
[ 0     c    0              0            ]
[ 0     0   (f+n)/(n−f)    2fn/(n−f)     ]
[ 0     0   −1              0            ]
  • the −1 row puts w = −z_eye, so the divide carries eye-space depth
  • depth is nonlinear: z_ndc ∝ 1/z_eye, so precision bunches near n → use a float + reversed-Z buffer to reclaim it
  • D3D/VK clip z ∈ [0,1]: the third row differs — f/(f−n) and −fn/(f−n)

rasterization

  • edge function: E(a,b,p) = (p.x−a.x)(b.y−a.y) − (p.y−a.y)(b.x−a.x); its sign is which side of ab
  • inside test: all three edge functions share the triangle's winding sign; 2·area = E(a,b,c)
  • barycentrics: (λ₀,λ₁,λ₂) = (E_bc, E_ca, E_ab)/(2·area); P = λ₀A + λ₁B + λ₂C
  • top-left rule: count an edge pixel only on top/left edges → shared edges don't double-shade
  • perspective-correct interp: lerp attr/w and 1/w linearly in screen space, then attr = (attr/w)ₗₑᵣₚ / (1/w)ₗₑᵣₚ — lerping attr directly is affine-wrong (the classic warped-texture bug)
  • backface cull: the sign of the screen-space signed area is the winding; drop the back-facing sign

raycasting (wolfenstein 3d)

per screen column x: cameraX = 2x/W − 1; rayDir = dir + plane·cameraX (dir = forward, plane ⟂ dir with |plane| = tan(fov/2)).

deltaDist.x = |1 / rayDir.x|              # dist between x grid lines
step, sideDist = initial cell + direction
loop:
    advance on the axis with the smaller sideDist
    sideDist[axis] += deltaDist[axis];  side = axis
until map[cell] is solid
perpDist = sideDist[side] − deltaDist[side]   # perpendicular, NOT euclidean
  • use perpDist (project onto the camera direction), never the ray length — euclidean distance gives the fisheye warp
  • wall column height = H / perpDist, drawn centered on the horizon
  • texX from the fractional hit along the wall; texStep = texH / wallH

bsp / doom (2.5-d)

  • vertical walls only, sectors carry floor/ceiling heights — no room-over-room
  • BSP tree splits the map along linedefs; traverse front-to-back from the camera → exact occlusion with no z-buffer
  • occlusion: keep a per-column clip list (solidsegs); each wall seg fills only still-open column spans
  • walls are 1/z-linear across the screen → column scale = focalLen / dist; texture v steps linearly down the column
  • floors/ceilings: visplanes drawn as horizontal spans (constant height ⇒ row maps to a distance)

raytracing

  • ray: P(t) = O + tD, t > 0
  • sphere (center C, radius r): a = D·D, b = 2D·(O−C), c = |O−C|² − r²; t = (−b ± √(b²−4ac)) / 2a
  • plane (point p₀, normal n): t = (p₀−O)·n / (D·n)
  • triangle — Möller–Trumbore:
e1 = v1−v0;  e2 = v2−v0
h = D × e2;   a = e1 · h           # |a| < ε  ⇒  ray ∥ triangle
f = 1/a;  s = O − v0
u = f (s · h);        miss if u < 0 or u > 1
q = s × e1
v = f (D · q);        miss if v < 0 or u+v > 1
t = f (e2 · q);       hit if t > ε
  • reflect: R = D − 2(D·n)n
  • refract (Snell, η = η₁/η₂, cosθᵢ = −D·n): k = 1 − η²(1 − cosθᵢ²); k < 0 ⇒ total internal reflection; else T = ηD + (η cosθᵢ − √k)n
  • fresnel (schlick): R(θ) = R₀ + (1−R₀)(1−cosθ)⁵, with R₀ = ((η₁−η₂)/(η₁+η₂))²
  • rendering equation: Lₒ(x,ωₒ) = Lₑ + ∫_Ω f_r(x,ωᵢ,ωₒ) Lᵢ(x,ωᵢ)(ωᵢ·n) dωᵢ