Files
skills/shader-dev/techniques/voxel-rendering.md
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

45 KiB

WebGL2 Adaptation Requirements

The code templates in this document use ShaderToy GLSL style. When generating standalone HTML pages, you must adapt for WebGL2:

  • Use canvas.getContext("webgl2") (required! WebGL1 does not support in/out keywords)
  • Shader first line: #version 300 es, add precision highp float; to fragment shader
  • IMPORTANT: #version must be the very first line of the shader! No characters before it (including blank lines/comments/Unicode BOM)
  • Vertex shader: attributein, varyingout
  • Fragment shader: varyingin, gl_FragColor → custom out vec4 fragColor, texture2D()texture()
  • ShaderToy's void mainImage(out vec4 fragColor, in vec2 fragCoord) needs to be adapted to the standard void main() entry point

WebGL2 Full Adaptation Example

// === Vertex Shader ===
const vertexShaderSource = `#version 300 es
in vec2 a_position;
void main() {
    gl_Position = vec4(a_position, 0.0, 1.0);
}`;

// === Fragment Shader ===
const fragmentShaderSource = `#version 300 es
precision highp float;

uniform float iTime;
uniform vec2 iResolution;

// IMPORTANT: Important: WebGL2 must declare the output variable!
out vec4 fragColor;

// ... other functions ...

void main() {
    // IMPORTANT: Use gl_FragCoord.xy instead of fragCoord
    vec2 fragCoord = gl_FragCoord.xy;

    vec3 col = vec3(0.0);

    // ... rendering logic ...

    // IMPORTANT: Write to fragColor, not gl_FragColor!
    fragColor = vec4(col, 1.0);
}`;

IMPORTANT: Common GLSL compile errors:

  • in/out storage qualifier supported in GLSL ES 3.00 only → Check that you are using getContext("webgl2") and #version 300 es
  • #version directive must occur on the first line → Check that the shader string starts with #version, with no characters before it
  • IMPORTANT: GLSL reserved words: cast, class, template, namespace, union, enum, typedef, sizeof, input, output, filter, image, sampler, fixed, volatile, public, static, extern, external, interface, long, short, double, half, unsigned, superp, inline, noinline, etc. are all GLSL reserved words and must never be used as variable or function names! Common pitfall: naming a function cast for ray casting → compile failure. Use compound names like castRay, castShadow, shootRay instead.
  • IMPORTANT: GLSL strict typing: float/int cannot be mixed. if (x > 0) for int, if (y < 0.0) for float. Comparing ivec3 members to float requires explicit conversion: float(c.y) < height. When getVoxel returns int, compare with > 0 not > 0.0. Function parameter types must match exactly.
  • IMPORTANT: Vector dimension mismatch (vec2 vs vec3): p.xz returns vec2 and must never be added to vec3 or passed to functions expecting vec3 parameters (e.g., fbm(vec3), noise(vec3))! Common error: fbm(p.xz * 0.08 + vec3(...))vec2 + vec3 compile failure. Fix: either use a vec2 version of noise/fbm, or construct a full vec3: fbm(vec3(p.xz * 0.08, p.y * 0.05)). Similarly, vec2 only has .x/.y, cannot access .z/.w.
  • IMPORTANT: length() / floating-point precision: length(ivec2) must first convert to vec2: length(vec2(d)). Exact floating-point equality comparison almost never works; use range comparison: floor(p.y) == floor(height)

Voxel Rendering Skill

Use Cases

  • Rendering discrete volumetric data on regular 3D grids (Minecraft-style worlds, medical volume data, architectural voxel models)
  • Pixel-accurate block/cube scenes
  • "Block art", "3D pixel art", "low-poly voxel" visual styles
  • Real-time voxel scenes in pure fragment shader environments like ShaderToy
  • Advanced lighting effects including shadows, AO, and global illumination

Core Principles

The core of voxel rendering is the DDA (Digital Differential Analyzer) ray traversal algorithm: cast a ray from the camera through each pixel, stepping through the 3D grid cell by cell along the ray direction until hitting an occupied voxel.

For ray P(t) = rayPos + t * rayDir, DDA maintains:

  • mapPos = floor(rayPos): current grid coordinate (integer)
  • deltaDist = abs(1.0 / rayDir): t cost to cross one cell
  • sideDist = (sign(rayDir) * (mapPos - rayPos) + sign(rayDir) * 0.5 + 0.5) * deltaDist: t distance to the next boundary on each axis

Each step advances along the axis with the smallest sideDist, updating sideDist += deltaDist and mapPos += rayStep.

Normal on hit: normal = -mask * rayStep

Face UV is obtained by projecting the hit point onto the two tangent axes of the hit face.

Implementation Steps

Step 1: Camera Ray Construction

vec2 screenPos = (fragCoord.xy / iResolution.xy) * 2.0 - 1.0;
vec3 cameraDir = vec3(0.0, 0.0, 0.8);  // Focal length; larger = narrower FOV
vec3 cameraPlaneU = vec3(1.0, 0.0, 0.0);
vec3 cameraPlaneV = vec3(0.0, 1.0, 0.0) * iResolution.y / iResolution.x;
vec3 rayDir = cameraDir + screenPos.x * cameraPlaneU + screenPos.y * cameraPlaneV;
vec3 rayPos = vec3(0.0, 2.0, -12.0);

Step 2: DDA Initialization

ivec3 mapPos = ivec3(floor(rayPos));
vec3 rayStep = sign(rayDir);
vec3 deltaDist = abs(1.0 / rayDir);  // When ray is normalized, equivalent to abs(1.0/rd), no length() needed
vec3 sideDist = (sign(rayDir) * (vec3(mapPos) - rayPos) + (sign(rayDir) * 0.5) + 0.5) * deltaDist;

Step 3: DDA Traversal Loop (Branchless Version)

#define MAX_RAY_STEPS 64

bvec3 mask;
for (int i = 0; i < MAX_RAY_STEPS; i++) {
    if (getVoxel(mapPos)) break;
    // Branchless axis selection
    mask = lessThanEqual(sideDist.xyz, min(sideDist.yzx, sideDist.zxy));
    sideDist += vec3(mask) * deltaDist;
    mapPos += ivec3(vec3(mask)) * ivec3(rayStep);
}

Alternative form (step version):

vec3 mask = step(sideDist.xyz, sideDist.yzx) * step(sideDist.xyz, sideDist.zxy);
sideDist += mask * deltaDist;
mapPos += mask * rayStep;

Step 4: Voxel Occupancy Function

// Basic version: solid block (most common; use this when user asks for "voxel cube")
// IMPORTANT: Important: getVoxel receives ivec3, but all internal calculations must use float!
bool getVoxel(ivec3 c) {
    vec3 p = vec3(c) + vec3(0.5);  // ivec3 → vec3 conversion (required!)
    float d = sdBox(p, vec3(6.0));  // Solid 12x12x12 cube
    return d < 0.0;
}

// Advanced version: SDF boolean operations (sphere carved from box = only corners remain)
bool getVoxelCarved(ivec3 c) {
    vec3 p = vec3(c) + vec3(0.5);
    float d = max(-sdSphere(p, 7.5), sdBox(p, vec3(6.0)));  // box ∩ ¬sphere
    return d < 0.0;
}

// Advanced version: height map terrain with material IDs
// IMPORTANT: Key: all comparisons must use float! c.y is int and must be converted to float for comparison
// IMPORTANT: Important: must use range comparison, not exact equality (floating-point precision issues)
int getVoxelMaterial(ivec3 c) {
    vec3 p = vec3(c);  // ivec3 → vec3 conversion (required!)
    float groundHeight = getTerrainHeight(p.xz);  // p.xz is vec2, passes float parameters
    if (float(c.y) < groundHeight) return 1;       // int → float comparison
    if (float(c.y) < groundHeight + 4.0) return 7;  // int → float comparison
    return 0;
}

// Pure float version (simpler, recommended):
int getVoxelMaterial(vec3 c) {
    float groundHeight = getTerrainHeight(c.xz);
    // IMPORTANT: Use range comparison, never exact equality!
    if (c.y >= groundHeight && c.y < groundHeight + 1.0) return 1;  // Grass top layer
    if (c.y >= groundHeight - 3.0 && c.y < groundHeight) return 2; // Dirt layer
    if (c.y < groundHeight - 3.0) return 3;  // Stone layer
    return 0;
}

// Advanced version: mountain terrain (height-based coloring: grass green → rock gray → snow white)
// IMPORTANT: Key 1: color thresholds must be based on heightRatio (normalized height 0~1), not absolute height!
// IMPORTANT: Key 2: maxH must match the actual maximum return value of getMountainHeight!
//           If getMountainHeight returns at most 15.0, maxH must be 15.0, not arbitrarily 20.0
// IMPORTANT: Key 3: threshold spacing must be large enough (at least 0.2), otherwise color bands are too narrow to see
// IMPORTANT: Key 4: grass area typically covers the largest terrain area (low elevation); set grass threshold high (0.4) to ensure green is clearly visible
float maxH = 15.0;  // IMPORTANT: Must equal the actual max value of getMountainHeight!
int getMountainVoxel(vec3 c) {
    float height = getMountainHeight(c.xz);  // Returns 0 ~ maxH
    if (c.y > height) return 0;  // Air
    float heightRatio = c.y / maxH;  // Normalize to 0~1
    // IMPORTANT: Thresholds from low to high: grass < 0.4, rock 0.4~0.7, snow > 0.7
    if (heightRatio < 0.4) return 1;  // Grass (green) — largest area
    if (heightRatio < 0.7) return 2;  // Rock (gray)
    return 3;                          // Snow cap (white)
}
// IMPORTANT: Corresponding material colors must have sufficient saturation and clear contrast:
// mat==1: vec3(0.25, 0.55, 0.15)  Grass green (saturated green, must not be grayish!)
// mat==2: vec3(0.5, 0.45, 0.4)   Rock gray-brown
// mat==3: vec3(0.92, 0.93, 0.96) Snow white
// IMPORTANT: Lighting must not be too bright or it washes out colors! Sun intensity ≤ 2.0, sky light ≤ 1.0
// IMPORTANT: Gamma correction pow(col, vec3(0.4545)) brightens dark colors and reduces saturation;
//    if colors look grayish-white, make grass green more saturated: vec3(0.2, 0.5, 0.1)

// IMPORTANT: Rotating objects: to rotate a voxel object, apply inverse rotation to the sample point in getVoxel!
// Do not rotate the camera to simulate object rotation (that only changes the viewpoint)
bool getVoxelRotating(ivec3 c) {
    vec3 p = vec3(c) + vec3(0.5);
    // Rotate around Y axis: apply inverse rotation to sample point
    float angle = -iTime;  // Negative sign = inverse transform
    float s = sin(angle), co = cos(angle);
    p.xz = vec2(p.x * co - p.z * s, p.x * s + p.z * co);
    float d = sdBox(p, vec3(6.0));  // Rotated solid cube
    return d < 0.0;
}

Step 5: Face Shading (Normal + Base Color)

vec3 normal = -vec3(mask) * rayStep;
vec3 color;
if (mask.x) color = vec3(0.5);   // Side faces darkest
if (mask.y) color = vec3(1.0);   // Top face brightest
if (mask.z) color = vec3(0.75);  // Front/back faces medium
fragColor = vec4(color, 1.0);

Step 6: Precise Hit Position and Face UV

float t = dot(sideDist - deltaDist, vec3(mask));
vec3 hitPos = rayPos + rayDir * t;
vec3 uvw = hitPos - vec3(mapPos);
vec2 uv = vec2(dot(vec3(mask) * uvw.yzx, vec3(1.0)),
               dot(vec3(mask) * uvw.zxy, vec3(1.0)));

Step 7: Neighbor Voxel AO

float vertexAo(vec2 side, float corner) {
    return (side.x + side.y + max(corner, side.x * side.y)) / 3.0;
}

vec4 voxelAo(vec3 pos, vec3 d1, vec3 d2) {
    vec4 side = vec4(
        getVoxel(pos + d1), getVoxel(pos + d2),
        getVoxel(pos - d1), getVoxel(pos - d2));
    vec4 corner = vec4(
        getVoxel(pos + d1 + d2), getVoxel(pos - d1 + d2),
        getVoxel(pos - d1 - d2), getVoxel(pos + d1 - d2));
    vec4 ao;
    ao.x = vertexAo(side.xy, corner.x);
    ao.y = vertexAo(side.yz, corner.y);
    ao.z = vertexAo(side.zw, corner.z);
    ao.w = vertexAo(side.wx, corner.w);
    return 1.0 - ao;
}

// Bilinear interpolation
vec4 ambient = voxelAo(mapPos - rayStep * mask, mask.zxy, mask.yzx);
float ao = mix(mix(ambient.z, ambient.w, uv.x), mix(ambient.y, ambient.x, uv.x), uv.y);
ao = pow(ao, 1.0 / 3.0);  // Gamma correction to control AO intensity

Step 8: DDA Shadow Ray

// IMPORTANT: Shadow steps must be capped at 16; total main ray + shadow ray steps should not exceed 80
#define MAX_SHADOW_STEPS 16

float castShadow(vec3 ro, vec3 rd) {
    vec3 pos = floor(ro);
    vec3 ri = 1.0 / rd;
    vec3 rs = sign(rd);
    vec3 dis = (pos - ro + 0.5 + rs * 0.5) * ri;
    for (int i = 0; i < MAX_SHADOW_STEPS; i++) {
        if (getVoxel(ivec3(pos))) return 0.0;
        vec3 mm = step(dis.xyz, dis.yzx) * step(dis.xyz, dis.zxy);
        dis += mm * rs * ri;
        pos += mm * rs;
    }
    return 1.0;
}

vec3 sundir = normalize(vec3(-0.5, 0.6, 0.7));
float shadow = castShadow(hitPos + normal * 0.01, sundir);
float diffuse = max(dot(normal, sundir), 0.0) * shadow;

Complete Code Template

// === Voxel Rendering - Complete ShaderToy Template ===
// Includes: DDA traversal, face shading, neighbor AO, hard shadows

// IMPORTANT: Performance critical: SwiftShader software renderer (headless browser evaluation environment) cannot handle too many loop iterations
// Default 64+16=80 steps, suitable for most scenes. Simple scenes (single cube) can increase to 96+24
// Multi-building/character/Minecraft scenes must keep 64+16 or lower!
#define MAX_RAY_STEPS 64
#define MAX_SHADOW_STEPS 16
#define GRID_SIZE 16.0

// ---- Math Utilities ----
float sdSphere(vec3 p, float r) { return length(p) - r; }
float sdBox(vec3 p, vec3 b) {
    vec3 d = abs(p) - b;
    return min(max(d.x, max(d.y, d.z)), 0.0) + length(max(d, 0.0));
}
float hash31(vec3 n) { return fract(sin(dot(n, vec3(1.0, 113.0, 257.0))) * 43758.5453); }

vec2 rotate2d(vec2 v, float a) {
    float s = sin(a), c = cos(a);
    return vec2(v.x * c - v.y * s, v.y * c + v.x * s);
}

// ---- Voxel Scene Definition ----
// IMPORTANT: Default solid cube. Use sdBox for "voxel cube"; add SDF boolean ops for carved/sculpted shapes
int getVoxel(vec3 c) {
    vec3 p = c + 0.5;
    float d = sdBox(p, vec3(6.0));  // Solid 12x12x12 block
    if (d < 0.0) {
        if (p.y < -3.0) return 2;
        return 1;
    }
    return 0;
}

// ---- Neighbor AO ----
float getOccupancy(vec3 c) { return float(getVoxel(c) > 0); }

float vertexAo(vec2 side, float corner) {
    return (side.x + side.y + max(corner, side.x * side.y)) / 3.0;
}

vec4 voxelAo(vec3 pos, vec3 d1, vec3 d2) {
    vec4 side = vec4(
        getOccupancy(pos + d1), getOccupancy(pos + d2),
        getOccupancy(pos - d1), getOccupancy(pos - d2));
    vec4 corner = vec4(
        getOccupancy(pos + d1 + d2), getOccupancy(pos - d1 + d2),
        getOccupancy(pos - d1 - d2), getOccupancy(pos + d1 - d2));
    vec4 ao;
    ao.x = vertexAo(side.xy, corner.x);
    ao.y = vertexAo(side.yz, corner.y);
    ao.z = vertexAo(side.zw, corner.z);
    ao.w = vertexAo(side.wx, corner.w);
    return 1.0 - ao;
}

// ---- DDA Traversal Core ----
struct HitInfo {
    bool  hit;
    float t;
    vec3  pos;
    vec3  normal;
    vec3  mapPos;
    vec2  uv;
    int   mat;
};

HitInfo castRay(vec3 ro, vec3 rd, int maxSteps) {
    HitInfo info;
    info.hit = false;
    info.t = 0.0;

    vec3 mapPos = floor(ro);
    vec3 rayStep = sign(rd);
    vec3 deltaDist = abs(1.0 / rd);
    vec3 sideDist = (rayStep * (mapPos - ro) + rayStep * 0.5 + 0.5) * deltaDist;
    vec3 mask = vec3(0.0);

    for (int i = 0; i < maxSteps; i++) {
        int vox = getVoxel(mapPos);
        if (vox > 0) {
            info.hit = true;
            info.mat = vox;
            info.normal = -mask * rayStep;
            info.mapPos = mapPos;
            info.t = dot(sideDist - deltaDist, mask);
            info.pos = ro + rd * info.t;
            vec3 uvw = info.pos - mapPos;
            info.uv = vec2(dot(mask * uvw.yzx, vec3(1.0)),
                           dot(mask * uvw.zxy, vec3(1.0)));
            return info;
        }
        mask = step(sideDist.xyz, sideDist.yzx) * step(sideDist.xyz, sideDist.zxy);
        sideDist += mask * deltaDist;
        mapPos += mask * rayStep;
    }
    return info;
}

// ---- Shadow Ray ----
// IMPORTANT: Shadow steps at 16 (combined with main ray 64 = 80, within SwiftShader safe range)
float castShadow(vec3 ro, vec3 rd) {
    vec3 pos = floor(ro);
    vec3 ri = 1.0 / rd;
    vec3 rs = sign(rd);
    vec3 dis = (pos - ro + 0.5 + rs * 0.5) * ri;
    for (int i = 0; i < MAX_SHADOW_STEPS; i++) {
        // IMPORTANT: getVoxel returns int; comparison must use int constant (0), not float (0.0)
        if (getVoxel(pos) > 0) return 0.0;
        vec3 mm = step(dis.xyz, dis.yzx) * step(dis.xyz, dis.zxy);
        dis += mm * rs * ri;
        pos += mm * rs;
    }
    return 1.0;
}

// ---- Material Colors ----
// IMPORTANT: Texture coloring key: "low saturation" does not mean "near white/gray"!
// Low saturation = colorful but not vivid, must retain clear hue differences (e.g., brick red 0.55,0.35,0.3 not gray-white 0.8,0.8,0.8)
// Brick/stone textures: use UV periodic patterns (mortar lines = dark lines), never use solid colors!
vec3 getMaterialColor(int mat, vec2 uv) {
    vec3 col = vec3(0.6);
    if (mat == 1) col = vec3(0.7, 0.7, 0.75);
    if (mat == 2) col = vec3(0.4, 0.55, 0.3);
    float checker = mod(floor(uv.x * 4.0) + floor(uv.y * 4.0), 2.0);
    col *= 0.85 + 0.15 * checker;
    return col;
}

// ---- Brick/Stone Texture Coloring (use this to replace getMaterialColor when user requests "brick texture") ----
// IMPORTANT: Key: brick texture = UV periodic pattern (staggered rows + mortar dark lines), not solid color!
vec3 getBrickColor(vec2 uv, vec3 baseColor, vec3 mortarColor) {
    vec2 brickUV = uv * vec2(4.0, 8.0);
    float row = floor(brickUV.y);
    brickUV.x += mod(row, 2.0) * 0.5;  // Staggered row offset
    vec2 f = fract(brickUV);
    float mortar = step(f.x, 0.06) + step(f.y, 0.08);  // Mortar joints
    mortar = clamp(mortar, 0.0, 1.0);
    float noise = fract(sin(dot(floor(brickUV), vec2(12.9898, 78.233))) * 43758.5453);
    vec3 brickVariation = baseColor * (0.85 + 0.3 * noise);  // Slight color variation per brick
    return mix(brickVariation, mortarColor, mortar);
}
// Usage example (maze walls):
// if (mat == 1) col = getBrickColor(uv, vec3(0.55, 0.35, 0.3), vec3(0.4, 0.38, 0.35)); // Brick red + mortar
// if (mat == 2) col = getBrickColor(uv, vec3(0.5, 0.48, 0.42), vec3(0.35, 0.33, 0.3)); // Gray stone brick

// ---- Main Function ----
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 screenPos = (fragCoord.xy / iResolution.xy) * 2.0 - 1.0;
    screenPos.x *= iResolution.x / iResolution.y;

    vec3 ro = vec3(0.0, 2.0 * sin(iTime * 0.5), -12.0);
    vec3 forward = vec3(0.0, 0.0, 0.8);
    vec3 rd = normalize(forward + vec3(screenPos, 0.0));

    ro.xz = rotate2d(ro.xz, iTime * 0.3);
    rd.xz = rotate2d(rd.xz, iTime * 0.3);

    vec3 sunDir = normalize(vec3(-0.5, 0.6, 0.7));
    vec3 skyColor = vec3(0.6, 0.75, 0.9);

    HitInfo hit = castRay(ro, rd, MAX_RAY_STEPS);

    vec3 col;
    if (hit.hit) {
        vec3 matCol = getMaterialColor(hit.mat, hit.uv);

        vec3 mask = abs(hit.normal);
        vec4 ambient = voxelAo(hit.mapPos, mask.zxy, mask.yzx);
        float ao = mix(
            mix(ambient.z, ambient.w, hit.uv.x),
            mix(ambient.y, ambient.x, hit.uv.x),
            hit.uv.y);
        ao = pow(ao, 0.5);

        float shadow = castShadow(hit.pos + hit.normal * 0.01, sunDir);

        float diff = max(dot(hit.normal, sunDir), 0.0);
        float sky = 0.5 + 0.5 * hit.normal.y;

        vec3 lighting = vec3(0.0);
        // IMPORTANT: Mountain/terrain scenes: sun light ≤ 2.0, sky light ≤ 1.0; too bright washes out material color differences
        lighting += 2.0 * diff * vec3(1.0, 0.95, 0.8) * shadow;
        lighting += 1.0 * sky * skyColor;
        lighting *= ao;

        col = matCol * lighting;

        // IMPORTANT: Fog: coefficient should not be too large, otherwise nearby objects get swallowed into pure sky color
        // 0.0002 suits GRID_SIZE=16 scenes; use smaller coefficients for larger scenes
        float fog = 1.0 - exp(-0.0002 * hit.t * hit.t);
        col = mix(col, skyColor, clamp(fog, 0.0, 0.7));  // Clamp prevents objects from disappearing entirely
    } else {
        col = skyColor - rd.y * 0.2;
    }

    col = pow(clamp(col, 0.0, 1.0), vec3(0.4545));
    fragColor = vec4(col, 1.0);
}

Common Variants

Variant 1: Glowing Voxels (Glow Accumulation)

Accumulate distance-based glow values during DDA traversal; produces semi-transparent glow even on miss.

float glow = 0.0;
for (int i = 0; i < MAX_RAY_STEPS; i++) {
    float d = sdSomeShape(vec3(mapPos));
    glow += 0.015 / (0.01 + d * d);
    if (d < 0.0) break;
    // ... normal DDA stepping ...
}
vec3 col = baseColor + glow * vec3(0.4, 0.6, 1.0);

Variant 2: Rounded Voxels (Intra-voxel SDF Refinement)

After DDA hit, perform SDF ray march inside the voxel to render rounded blocks.

float id = hash31(mapPos);
float w = 0.05 + 0.35 * id;

float sdRoundedBox(vec3 p, float w) {
    return length(max(abs(p) - 0.5 + w, 0.0)) - w;
}

vec3 localP = hitPos - mapPos - 0.5;
for (int j = 0; j < 6; j++) {
    float h = sdRoundedBox(localP, w);
    if (h < 0.025) break;
    localP += rd * max(0.0, h);
}

Variant 3: Hybrid SDF-Voxel Traversal

SDF sphere-tracing with large steps at distance, switching to precise DDA near the surface.

#define VOXEL_SIZE 0.0625
#define SWITCH_DIST (VOXEL_SIZE * 1.732)

bool useVoxel = false;
for (int i = 0; i < MAX_STEPS; i++) {
    vec3 pos = ro + rd * t;
    float d = mapSDF(useVoxel ? voxelCenter : pos);
    if (!useVoxel) {
        t += d;
        if (d < SWITCH_DIST) { useVoxel = true; voxelPos = getVoxelPos(pos); }
    } else {
        if (d < 0.0) break;
        if (d > SWITCH_DIST) { useVoxel = false; t += d; continue; }
        vec3 exitT = (voxelPos - ro * ird + ird * VOXEL_SIZE * 0.5);
        // ... select minimum axis to advance ...
    }
}

Variant 4: Voxel Cone Tracing

Build multi-level mipmaps, cast cone-shaped rays from hit points for global illumination.

vec4 traceCone(vec3 origin, vec3 dir, float coneRatio) {
    vec4 light = vec4(0.0);
    float t = 1.0;
    for (int i = 0; i < 58; i++) {
        vec3 sp = origin + dir * t;
        float diameter = max(1.0, t * coneRatio);
        float lod = log2(diameter);
        vec4 sample = voxelFetch(sp, lod);
        light += sample * (1.0 - light.w);
        t += diameter;
    }
    return light;
}

Variant 5: PBR Lighting + Multi-Bounce Reflection

GGX BRDF replacing Lambert, with metallic/roughness parameters; cast a second DDA ray for reflections.

float ggxDiffuse(float NoL, float NoV, float LoH, float roughness) {
    float FD90 = 0.5 + 2.0 * roughness * LoH * LoH;
    float a = 1.0 + (FD90 - 1.0) * pow(1.0 - NoL, 5.0);
    float b = 1.0 + (FD90 - 1.0) * pow(1.0 - NoV, 5.0);
    return a * b / 3.14159;
}

vec3 rd2 = reflect(rd, normal);
HitInfo reflHit = castRay(hitPos + normal * 0.001, rd2, 64);
vec3 reflColor = reflHit.hit ? shade(reflHit) : skyColor;

float fresnel = 0.04 + 0.96 * pow(1.0 - max(dot(normal, -rd), 0.0), 5.0);
col += fresnel * reflColor;

Variant 6: Voxel Water Scene (Water + Underwater Voxels)

Water surface ripple reflections, underwater refraction, sand and seaweed for a complete water scene.

float waterY = 0.0;

// Underwater voxel scene definition (sand + seaweed)
// IMPORTANT: All coordinate operations must use correct vector dimensions!
// c.xz returns vec2, only has .x/.y components, cannot use .z!
int getVoxel(vec3 c) {
    float sandHeight = -3.0 + 0.5 * sin(c.x * 0.3) * cos(c.z * 0.4);
    if (c.y < sandHeight) return 1;      // Sand interior
    if (c.y < sandHeight + 1.0) return 2; // Sand surface
    // Seaweed: only grows underwater, above sand
    float grassHash = fract(sin(dot(floor(c.xz), vec2(12.9898, 78.233))) * 43758.5453);
    // IMPORTANT: floor(c.xz) is vec2; the second argument to dot() must also be vec2
    if (grassHash > 0.85 && c.y >= sandHeight + 1.0 && c.y < sandHeight + 1.0 + 3.0 * grassHash) {
        return 3;  // Seaweed
    }
    return 0;
}

// Handle water surface in main rendering
float tWater = (waterY - ro.y) / rd.y;
bool hitWater = tWater > 0.0 && (tWater < hit.t || !hit.hit);

if (hitWater) {
    vec3 waterPos = ro + rd * tWater;
    vec3 waterNormal = vec3(0.0, 1.0, 0.0);
    // IMPORTANT: waterPos.xz is vec2; access with .x/.y (not .x/.z)
    vec2 waveXZ = waterPos.xz;  // vec2: waveXZ.x = worldX, waveXZ.y = worldZ
    waterNormal.x += 0.05 * sin(waveXZ.x * 3.0 + iTime);
    waterNormal.z += 0.05 * cos(waveXZ.y * 2.0 + iTime * 0.7);
    waterNormal = normalize(waterNormal);

    float fresnel = 0.04 + 0.96 * pow(1.0 - max(dot(waterNormal, -rd), 0.0), 5.0);

    // Reflection
    vec3 reflDir = reflect(rd, waterNormal);
    HitInfo reflHit = castRay(waterPos + waterNormal * 0.01, reflDir, 64);
    vec3 reflCol = reflHit.hit ? getMaterialColor(reflHit.mat, reflHit.uv) : skyColor;

    // Refraction (underwater voxels: sand, seaweed)
    vec3 refrDir = refract(rd, waterNormal, 1.0 / 1.33);
    HitInfo refrHit = castRay(waterPos - waterNormal * 0.01, refrDir, 64);
    vec3 refrCol;
    if (refrHit.hit) {
        vec3 matCol = getMaterialColor(refrHit.mat, refrHit.uv);
        float underwaterDist = length(refrHit.pos - waterPos);
        refrCol = mix(matCol, vec3(0.0, 0.15, 0.3), 1.0 - exp(-0.1 * underwaterDist));
    } else {
        refrCol = vec3(0.0, 0.1, 0.3);
    }

    col = mix(refrCol, reflCol, fresnel);
    col = mix(col, vec3(0.0, 0.3, 0.5), 0.2);
}

Variant 7: Rotating Voxel Objects

Rotate voxel objects as a whole. Core: apply inverse rotation to sample points in getVoxel.

// IMPORTANT: Correct way to rotate objects: apply inverse rotation to sample coordinates in getVoxel
// Wrong approach: only rotate the camera (that just changes the viewpoint, not the object)
int getVoxel(vec3 c) {
    vec3 p = c + 0.5;
    // Rotate around Y axis
    float angle = -iTime * 0.5;
    float s = sin(angle), co = cos(angle);
    p.xz = vec2(p.x * co - p.z * s, p.x * s + p.z * co);
    // Can also rotate around multiple axes:
    // p.yz = vec2(p.y * co2 - p.z * s2, p.y * s2 + p.z * co2);  // X axis rotation
    float d = sdBox(p, vec3(6.0));
    if (d < 0.0) return 1;
    return 0;
}

Variant 8: Indoor/Cave/Enclosed Scenes (Point Lights + High Ambient Lighting)

Indoor, cave, underground, sci-fi base, and other enclosed or semi-enclosed scenes require point lights and high ambient lighting.

// IMPORTANT: Key points for enclosed/semi-enclosed scenes (caves, interiors, sci-fi bases, mazes, etc.):
// 1. Camera must be placed inside the cavity (a position where getVoxel returns 0)
// 2. Must use point lights, not just directional light (directional light blocked by walls/ceiling = total darkness!)
// 3. Ambient light must be high enough (at least 0.2-0.3) to prevent scene from being too dark to see details
// 4. Can use multiple point lights + emissive voxels to simulate torches/fluorescence/holographic displays
// 5. Sci-fi scene metallic walls need bright enough light sources to show reflections
// 6. Emissive elements (holographic screens, indicator lights, magic circles) use emissive materials: add emissive color directly to lighting

// Cave scene: cavity = area where getVoxel returns 0
// IMPORTANT: Cave/terrain noise functions must respect vector dimensions!
// p.xz is vec2; if noise/fbm function takes vec3, construct a full vec3:
//   Correct: fbm(vec3(p.xz, p.y * 0.5))  or use vec2 version of noise
//   Wrong: fbm(p.xz + vec3(...))  ← vec2 + vec3 compile failure!
int getVoxel(vec3 c) {
    float cave = sdSphere(c + 0.5, 12.0);
    // IMPORTANT: For noise-carved detail, use c's components directly (all float)
    cave += 2.0 * sin(c.x * 0.3) * sin(c.y * 0.4) * sin(c.z * 0.35);
    if (cave > 0.0) return 1;  // Rock wall
    return 0;  // Cavity (camera goes here)
}

// Point light attenuation
vec3 pointLightPos = vec3(0.0, 3.0, 0.0);
vec3 toLight = pointLightPos - hit.pos;
float lightDist = length(toLight);
vec3 lightDir = toLight / lightDist;
float attenuation = 1.0 / (1.0 + 0.1 * lightDist + 0.01 * lightDist * lightDist);

float diff = max(dot(hit.normal, lightDir), 0.0);
float shadow = castShadow(hit.pos + hit.normal * 0.01, lightDir);

vec3 lighting = vec3(0.0);
// IMPORTANT: High ambient light to prevent total darkness (required for enclosed scenes! at least 0.2)
lighting += vec3(0.25, 0.22, 0.2);  // Warm ambient light
lighting += 3.0 * diff * attenuation * vec3(1.0, 0.8, 0.5) * shadow;  // Point light

// Multiple torches/emissive objects (use sin for flicker animation)
vec3 torch1 = vec3(5.0, 2.0, 3.0);
vec3 torch2 = vec3(-4.0, 1.0, -5.0);
float flicker1 = 0.8 + 0.2 * sin(iTime * 5.0 + 1.0);
float flicker2 = 0.8 + 0.2 * sin(iTime * 4.3 + 2.7);
lighting += calcPointLight(hit.pos, hit.normal, torch1, vec3(1.0, 0.6, 0.2)) * flicker1;
lighting += calcPointLight(hit.pos, hit.normal, torch2, vec3(0.2, 1.0, 0.5)) * flicker2;

// Emissive materials (holographic displays, fluorescent moss, indicator lights, magic circles, etc.)
// IMPORTANT: Emissive colors are added directly to lighting, unaffected by shadows
if (hit.mat == 2) {
    lighting += vec3(0.1, 0.4, 0.15);  // Fluorescent moss (faint green)
}
if (hit.mat == 3) {
    float pulse = 0.7 + 0.3 * sin(iTime * 2.0);
    lighting += vec3(0.2, 0.6, 1.0) * pulse;  // Blue pulse light
}

col = matCol * lighting;

Variant 9: Voxel Character Animation

Simple voxel character animation using time-driven offsets and rotations.

// IMPORTANT: Voxel character animation core approach:
// 1. Split the character into multiple body parts (head, torso, left arm, right arm, left leg, right leg)
// 2. Each part is an sdBox with independent offset/rotation parameters
// 3. iTime drives limb swinging (sin/cos periodic motion)
// 4. Combine all parts using SDF min()
// IMPORTANT: SwiftShader performance critical: character function is called at every DDA step!
//    Must add AABB bounding box check in getVoxel: first check if c is near the character,
//    skip sdBox calculations for that character if not nearby. Otherwise frame timeout → black screen
//    Reduce MAX_RAY_STEPS to 64, MAX_SHADOW_STEPS to 16

int getCharacter(vec3 p, vec3 charPos, float animPhase) {
    vec3 lp = p - charPos;
    float limbSwing = sin(iTime * 4.0 + animPhase) * 0.5;

    // Torso
    float body = sdBox(lp - vec3(0, 3, 0), vec3(1.5, 2.0, 1.0));
    // Head
    float head = sdBox(lp - vec3(0, 6, 0), vec3(1.2, 1.2, 1.2));

    // Arm swing (offset y coordinate around shoulder joint to simulate rotation)
    vec3 armOffset = vec3(0, limbSwing * 2.0, limbSwing);
    float leftArm = sdBox(lp - vec3(-2.5, 3, 0) - armOffset, vec3(0.5, 2.0, 0.5));
    float rightArm = sdBox(lp - vec3(2.5, 3, 0) + armOffset, vec3(0.5, 2.0, 0.5));

    // Alternating leg swing
    vec3 legOffset = vec3(0, 0, limbSwing * 1.5);
    float leftLeg = sdBox(lp - vec3(-0.7, 0, 0) - legOffset, vec3(0.5, 1.5, 0.5));
    float rightLeg = sdBox(lp - vec3(0.7, 0, 0) + legOffset, vec3(0.5, 1.5, 0.5));

    float d = min(body, min(head, min(leftArm, min(rightArm, min(leftLeg, rightLeg)))));
    if (d < 0.0) {
        if (head < 0.0) return 10;  // Head (skin color)
        if (leftArm < 0.0 || rightArm < 0.0) return 11;  // Arms
        return 12;  // Torso/legs
    }
    return 0;
}

// Combine scene + characters in getVoxel
// IMPORTANT: Must add AABB bounding box early exit! Character sdBox calculations are expensive
int getVoxel(vec3 c) {
    // Scene (floor, walls, etc.)
    int scene = getSceneVoxel(c);
    if (scene > 0) return scene;
    // IMPORTANT: AABB check: only call getCharacter near the character
    // Character 1: warrior (at position (5,0,0)), bounding box ±5 cells
    if (abs(c.x - 5.0) < 5.0 && c.y >= 0.0 && c.y < 10.0 && abs(c.z) < 5.0) {
        int char1 = getCharacter(c, vec3(5, 0, 0), 0.0);
        if (char1 > 0) return char1;
    }
    // Character 2: mage (at position (-5,0,3)), bounding box ±5 cells
    if (abs(c.x + 5.0) < 5.0 && c.y >= 0.0 && c.y < 10.0 && abs(c.z - 3.0) < 5.0) {
        int char2 = getCharacter(c, vec3(-5, 0, 3), 3.14);
        if (char2 > 0) return char2;
    }
    return 0;
}

Variant 10: Waterfall / Flowing Water Particle Effects

Dynamic waterfall, splash particles, water mist effects. Core: time-offset noise simulates water flow, hashed particles simulate splashes, exponential decay simulates mist.

// IMPORTANT: Key points for waterfall/flowing water/particle effects:
// 1. Waterfall stream: noise + iTime vertical offset simulates water column flowing down
// 2. Splash particles: hash-distributed voxels at the bottom, positions change with iTime to simulate splashing
// 3. Water mist: semi-transparent accumulation (reduced alpha) or density field at the bottom simulates mist diffusion
// 4. Waterfall must have a clear high point (cliff/rock wall) and low point (pool), drop ≥ 10 cells
// 5. Water stream material uses light blue-white + brightness flicker to simulate flowing water feel

float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }

int getVoxel(vec3 c) {
    // Cliff rock walls (both sides + back)
    if (c.x < -5.0 || c.x > 5.0) {
        if (c.y < 15.0 && c.z > -3.0 && c.z < 3.0) return 1;  // Rock
    }
    if (c.z > 2.0 && c.y < 15.0 && abs(c.x) < 6.0) return 1;  // Back wall

    // Cliff top platform
    if (c.y >= 13.0 && c.y < 15.0 && c.z > -1.0 && c.z < 3.0 && abs(c.x) < 5.0) return 1;

    // Bottom pool floor
    if (c.y < -2.0 && abs(c.x) < 8.0 && c.z > -6.0 && c.z < 3.0) return 2;  // Pool bottom

    // IMPORTANT: Waterfall stream: narrow band x ∈ [-2, 2], falling from y=13 to y=0
    //    Use iTime offset on y-coordinate noise to simulate downward water flow
    if (abs(c.x) < 2.0 && c.y >= 0.0 && c.y < 13.0 && c.z > -1.0 && c.z < 1.0) {
        float flowNoise = hash21(vec2(floor(c.x), floor(c.y - iTime * 8.0)));
        if (flowNoise > 0.25) return 3;  // Water (gaps simulate translucent water curtain)
    }

    // IMPORTANT: Splash particles: bottom y ∈ [-1, 3], x ∈ [-4, 4]
    //    Use hash + iTime to generate randomly bouncing voxel particles
    if (c.y >= -1.0 && c.y < 3.0 && abs(c.x) < 4.0 && c.z > -3.0 && c.z < 2.0) {
        float t = iTime * 3.0;
        float particleHash = hash21(vec2(floor(c.x * 2.0), floor(c.z * 2.0) + floor(t)));
        float yOffset = fract(t + particleHash) * 3.0;  // Particle upward trajectory
        if (abs(c.y - yOffset) < 0.6 && particleHash > 0.7) return 4;  // Splash particle
    }

    // IMPORTANT: Water mist: bottom y ∈ [-1, 2], wider range than splashes
    //    Density decreases with height and distance from waterfall center
    if (c.y >= -1.0 && c.y < 2.0 && abs(c.x) < 6.0 && c.z > -5.0 && c.z < 3.0) {
        float distFromCenter = length(vec2(c.x, c.z));
        float mistDensity = exp(-0.15 * distFromCenter) * exp(-0.5 * max(c.y, 0.0));
        float mistNoise = hash21(vec2(floor(c.x * 0.5 + iTime * 0.5), floor(c.z * 0.5)));
        if (mistNoise < mistDensity * 0.8) return 5;  // Water mist
    }

    return 0;
}

// Material colors
vec3 getMaterialColor(int mat, vec2 uv) {
    if (mat == 1) return vec3(0.45, 0.4, 0.35);    // Rock
    if (mat == 2) return vec3(0.35, 0.3, 0.25);    // Pool bottom
    if (mat == 3) {                                  // Water stream (shimmering blue-white)
        float shimmer = 0.8 + 0.2 * sin(uv.y * 20.0 + iTime * 10.0);
        return vec3(0.6, 0.8, 1.0) * shimmer;
    }
    if (mat == 4) return vec3(0.85, 0.92, 1.0);    // Splash (bright white)
    if (mat == 5) return vec3(0.7, 0.82, 0.9);     // Water mist (pale blue-white)
    return vec3(0.5);
}

// IMPORTANT: Water mist material needs special lighting: high emissive + translucent feel
// During shading:
if (hit.mat == 5) {
    lighting += vec3(0.4, 0.5, 0.6);  // Water mist emissive (unaffected by shadows)
}

// Camera: side angle slightly elevated, showing the full waterfall (top to bottom + bottom splashes and mist)
// ro = vec3(12.0, 10.0, -10.0), lookAt = vec3(0.0, 6.0, 0.0)

Variant 11: Multi-Building / Town / Minecraft-Style Scenes (Multi-Structure Town Composition)

Towns, villages, Minecraft-style worlds, and other scenes requiring multiple discrete structures (houses, trees, lampposts, etc.) placed on the ground. IMPORTANT: "Minecraft-like voxel scene" = multi-building scene; must follow the performance constraints of this template!

// IMPORTANT: Key points for multi-building scenes:
// 1. Define the ground first (height map or flat plane), ensure ground getVoxel returns correct material
// 2. Each building uses an independent helper function, receiving local coordinates, returning material ID
// 3. In getVoxel, check each building sequentially (using offset coordinates), return on first hit
// 4. Camera must be outside the scene facing the center, far enough to see the full view
// 5. IMPORTANT: Building coordinate ranges must be within DDA traversal range (MAX_RAY_STEPS * cell ≈ reachable distance)
// 6. IMPORTANT: Scene range should not be too large! Concentrate all buildings within -20~20 range, camera 30-50 cells away
// 7. IMPORTANT: SwiftShader performance critical: getVoxel must have AABB bounding box early exit!
//    Above ground (c.y > 0), check AABB range first; return 0 immediately if outside building area
//    Otherwise every DDA step checks all buildings → frame timeout → black screen / only sky renders
// 8. IMPORTANT: MAX_RAY_STEPS reduced to 64, MAX_SHADOW_STEPS to 16 (complex getVoxel requires lower step counts)

// Single house: width w, depth d, height h, with triangular roof
int makeHouse(vec3 p, float w, float d, float h, int wallMat, int roofMat) {
    // Walls
    if (p.x >= 0.0 && p.x < w && p.z >= 0.0 && p.z < d && p.y >= 0.0 && p.y < h) {
        return wallMat;
    }
    // Triangular roof: starts from wall top, x range narrows by 1 per level
    float roofY = p.y - h;
    float roofInset = roofY;  // Inset by 1 cell per level
    if (roofY >= 0.0 && roofY < w * 0.5
        && p.x >= roofInset && p.x < w - roofInset
        && p.z >= 0.0 && p.z < d) {
        return roofMat;
    }
    return 0;
}

// Tree: trunk + spherical canopy
int makeTree(vec3 p, float trunkH, float crownR, int trunkMat, int leafMat) {
    // Trunk (1x1 column)
    if (p.x >= -0.5 && p.x < 0.5 && p.z >= -0.5 && p.z < 0.5
        && p.y >= 0.0 && p.y < trunkH) {
        return trunkMat;
    }
    // Spherical canopy
    vec3 crownCenter = vec3(0.0, trunkH + crownR * 0.5, 0.0);
    if (length(p - crownCenter) < crownR) {
        return leafMat;
    }
    return 0;
}

// Lamppost: thin pole + glowing top block
int makeLamp(vec3 p, float h, int poleMat, int lightMat) {
    if (p.x >= -0.3 && p.x < 0.3 && p.z >= -0.3 && p.z < 0.3
        && p.y >= 0.0 && p.y < h) {
        return poleMat;  // Pole
    }
    if (p.x >= -0.5 && p.x < 0.5 && p.z >= -0.5 && p.z < 0.5
        && p.y >= h && p.y < h + 1.0) {
        return lightMat;  // Lamp head (emissive)
    }
    return 0;
}

int getVoxel(vec3 c) {
    // 1. Ground (y < 0 is underground, y == 0 layer is surface)
    if (c.y < -1.0) return 0;
    if (c.y < 0.0) return 1;  // Ground (dirt/grass)

    // 2. Road (along z direction, x range -2~2)
    if (c.y < 1.0 && abs(c.x) < 2.0) return 2;  // Road surface

    // IMPORTANT: AABB bounding box early exit (required for SwiftShader!)
    // All buildings are within x:-15~15, y:0~12, z:-5~15
    // Return 0 immediately outside this range, avoiding per-building checks
    if (c.x < -15.0 || c.x > 15.0 || c.y > 12.0 || c.z < -5.0 || c.z > 15.0) return 0;

    // 3. Place buildings (each with offset coordinates)
    // IMPORTANT: House width/height must be ≥ 5 cells, otherwise they look like dots from far away! Use bright material colors
    int m;

    // House A: position (5, 0, 3), width 6, depth 5, height 5
    m = makeHouse(c - vec3(5.0, 0.0, 3.0), 6.0, 5.0, 5.0, 3, 4);
    if (m > 0) return m;

    // House B: position (-10, 0, 2), width 7, depth 5, height 5
    m = makeHouse(c - vec3(-10.0, 0.0, 2.0), 7.0, 5.0, 5.0, 5, 4);
    if (m > 0) return m;

    // Tree: position (0, 0, 8)
    m = makeTree(c - vec3(0.0, 0.0, 8.0), 4.0, 2.5, 6, 7);
    if (m > 0) return m;

    // Lamppost: position (3, 0, 0)
    m = makeLamp(c - vec3(3.0, 0.0, 0.0), 5.0, 8, 9);
    if (m > 0) return m;

    return 0;
}

// IMPORTANT: Camera setup: must be far enough to overlook the entire town
// Recommended: ro = vec3(0, 15, -35), looking at scene center vec3(0, 3, 5)
vec3 ro = vec3(0.0, 15.0, -35.0);
vec3 lookAt = vec3(0.0, 3.0, 5.0);
vec3 forward = normalize(lookAt - ro);
vec3 right = normalize(cross(forward, vec3(0, 1, 0)));
vec3 up = cross(right, forward);
vec3 rd = normalize(forward * 0.8 + right * screenPos.x + up * screenPos.y);

// IMPORTANT: Sunset/side-lit scene key: when light comes from the side or at low angle, building fronts may be completely backlit turning into black silhouettes!
// Must satisfy all: (1) ambient light ≥ 0.3 (prevent backlit faces from going black); (2) house walls use bright materials (e.g., light yellow 0.85,0.75,0.55)
// (3) house dimensions must not be too small (width/height ≥ 5 cells), otherwise they look like dots from far away
vec3 sunDir = normalize(vec3(-0.8, 0.3, 0.5));  // Sunset low angle
vec3 sunColor = vec3(1.0, 0.6, 0.3);  // Warm orange
vec3 ambientColor = vec3(0.35, 0.3, 0.4);  // IMPORTANT: High ambient light (≥0.3) to prevent silhouettes
// lighting = ambientColor + diff * sunColor * shadow;

Performance & Composition

Performance Tips:

  • Early exit: break immediately when mapPos exceeds scene bounds
  • Shadow ray steps of 16-24 are sufficient
  • Use SDF sphere-tracing with large steps in open areas, switch to DDA near surfaces
  • Material queries, AO, normals, etc. are only computed after hit
  • Replace procedural voxel queries with texelFetch texture sampling
  • Multi-frame accumulation + reprojection for low-noise results
  • IMPORTANT: MAX_RAY_STEPS defaults to 64, MAX_SHADOW_STEPS defaults to 16 (total 80). Only simple scenes (single cube/sphere) can increase to 96+24. Multi-building/Minecraft/character scenes with complex getVoxel must keep 64+16 or lower, otherwise SwiftShader frame timeout → only sky background renders

Composition Tips:

  • Procedural noise terrain: use FBM/Perlin noise height maps inside getVoxel()
  • SDF procedural modeling: use SDF boolean operations inside getVoxel() to define shapes
  • Texture mapping: after hit, sample 16x16 pixel textures using face UV * 16
  • Atmospheric scattering / volumetric fog: accumulate medium density during DDA traversal
  • Water surface rendering: Fresnel reflection/refraction on a specific Y plane (see Variant 6 above)
  • Global illumination: cone tracing or Monte Carlo hemisphere sampling
  • Temporal reprojection: multi-frame accumulation + previous frame reprojection for anti-aliasing and denoising

Common Errors

  1. GLSL reserved words causing compile failure: cast, class, template, namespace, input, output, filter, image, sampler, half, fixed, etc. are GLSL reserved words and must never be used as variable or function names. Use compound names: castRay, castShadow, shootRay, spellEffect (not cast)
  2. Enclosed/semi-enclosed scene total darkness: caves, interiors, sci-fi bases, mazes, and other enclosed scenes cannot rely solely on directional light (completely blocked by walls/ceiling); must use point lights + high ambient light (≥0.2) + emissive materials (see Variant 8)
  3. Camera inside voxel causing rendering anomalies: cave/indoor scene camera origin must be inside the cavity (where getVoxel returns 0), otherwise the first DDA step hits immediately = scene invisible
  4. Complex getVoxel causing SwiftShader black screen (most common with Minecraft-style/town/character/multi-building scenes!): getVoxel is called once per DDA step; if it contains multiple buildings/characters/terrain+trees without early exit, frame timeout → only sky background renders. Must do all of: (1) AABB bounding box early exit (check coordinate range first, return 0 immediately outside building area); (2) MAX_RAY_STEPS ≤ 64, MAX_SHADOW_STEPS ≤ 16; (3) scene range within ±20 cells. Minecraft-style scene = multi-building scene; must follow this rule (see Variant 9, 11 template code)
  5. vec2/vec3 dimension mismatch causing compile failure: p.xz returns vec2 and cannot be passed directly to noise/fbm functions expecting vec3 parameters or used in operations with vec3. Use vec3(p.xz, val) to construct a full vec3, or use vec2 versions of functions
  6. Mountain/terrain height-based coloring invisible: (1) maxH must equal the actual max return value of the terrain noise function (don't arbitrarily use 20.0); (2) grass threshold at 0.4 (largest area ensures green is visible), rock 0.4~0.7, snow >0.7; (3) grass green must be saturated enough vec3(0.25, 0.55, 0.15) not grayish; (4) sun intensity ≤2.0, sky light ≤1.0, too bright washes out colors; (5) gamma correction reduces saturation, pre-compensate material colors (see Step 4 mountain terrain template)
  7. Waterfall/flowing water effect lacks recognizability: waterfall must have a clear cliff drop (≥10 cells), visible water column (noise + iTime offset), bottom splash particles (hash random bouncing), and mist (exponential decay density field). Just a gradient color block is not a waterfall! See Variant 10 complete template
  8. "Low saturation coloring" becomes pure white/gray: low saturation ≠ near white! Low saturation means colors are not vivid but still have clear hue (e.g., brick red vec3(0.55, 0.35, 0.3) not gray-white vec3(0.8, 0.8, 0.8)). Brick/stone textures must use UV periodic patterns (staggered rows + mortar dark lines), not solid colors. See the getBrickColor function in the complete template
  9. Sunset/side-lit scene buildings become black silhouettes: when low-angle light (sunset/dawn) illuminates from the side, building fronts are completely backlit → pure black silhouettes with no visible detail. Must: (1) ambient light ≥ 0.3; (2) walls use bright materials (light yellow, off-white) not dark colors; (3) buildings large enough (width/height ≥ 5 cells). See Variant 11 sunset scene code

Further Reading

For full step-by-step tutorials, mathematical derivations, and advanced usage, see reference