From ff8a8489b66524e933edb6bc846576b45093edc8 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 12 Apr 2026 14:57:41 -0700 Subject: [PATCH] arena: Z-buffer + adaptive perf, lava under whole arena, debug overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph: enable the dormant Z-buffer. clear() fills depthBuffer with Number.MAX_VALUE each frame; drawGradientTriangle / drawTexturedTriangle now correctly Z-test (lower NDC Z = nearer). Fixes lava-through-ground bleed without painter-order workarounds. Re-enables overlapping 3D forms. - arena: scrap the donut lava — it's a full plane now (Z-buffer handles occlusion). Adds groundSkirt backstop. Adaptive quality tiers (HIGH/MED/LOW) driven by rolling render-time: coarser lava tessellation, static lava (no stripe anim), and dropped body wireframes in LOW. HUD gets PERF label + list of disabled features. Once-per-second diagnostic snapshot + magenta pen crosshair to verify mouse raycast lines up with the hover highlight. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../public/aesthetic.computer/disks/arena.mjs | 273 ++++++++++++++---- .../public/aesthetic.computer/lib/graph.mjs | 201 +++++++------ 2 files changed, 320 insertions(+), 154 deletions(-) diff --git a/system/public/aesthetic.computer/disks/arena.mjs b/system/public/aesthetic.computer/disks/arena.mjs index 8b4b0e812f..d6e9137f1f 100644 --- a/system/public/aesthetic.computer/disks/arena.mjs +++ b/system/public/aesthetic.computer/disks/arena.mjs @@ -12,6 +12,8 @@ #endregion */ let groundPlane; +let groundSkirt; // solid opaque plate just under the ground that blocks + // any lava bleed-through between ground tile seams let shadowGround; // ring + cross — standing on the ground let shadowAir; // diagonal X — airborne let shadowCrouch; // dense inner dot + outer ring — crouched @@ -25,6 +27,11 @@ let penLocked = false; // Walk-cycle phase (advanced in sim while moving) for gentle arm/foot bob. let walkPhase = 0; +// 🐛 Debug: dump a snapshot of scene state once per second so it can be +// pasted back verbatim when something looks off. +let debugDumpTimer = 0; +const DEBUG_DUMP_INTERVAL = 120; // sim ticks (= 1 s at SIM_HZ=120) + // 💀 Death / respawn state let playerAlive = true; let deathTickAge = 0; // how long we've been dead (sim ticks, for UI fade-in) @@ -38,6 +45,22 @@ let hoverTile = null; let prevPlayerTile = null; const walkedTiles = new Map(); +// 🔍 Diagnostic: stash the last hit point + pen coords so paint() can draw a +// visible crosshair and the snapshot can log exactly where the ray landed. +let lastHitWorld = null; // [x, z] or null +let lastPenScreen = null; // [x, y] or null + +// ⚡ Adaptive-quality flags driven by measured render FPS. Auto-toggle in +// paint() based on the rolling frame-time average. Pieces can override via the +// HUD labels (future: click to pin). "LOW" = coarser tile, static lava, skip +// body wireframes. "MED" = static lava only. "HIGH" = everything on. +let perfLowMode = false; +let perfMedMode = false; +const PERF_LOW_MS = 25; // below ~40fps → drop to LOW +const PERF_MED_MS = 18; // below ~55fps → drop to MED +const PERF_HIGH_MS = 14; // above ~70fps → return to HIGH +let perfSamplesSinceSwitch = 0; + function tileKey(row, col) { return row * GRID + col; } function tileFromKey(k) { return { row: Math.floor(k / GRID), col: k % GRID }; } @@ -120,68 +143,50 @@ const BG = [45 / 255, 48 / 255, 55 / 255]; const COLOR_A = [0.38, 0.35, 0.30, 1.0]; const COLOR_B = [0.22, 0.20, 0.19, 1.0]; -// Build a fresh lava floor Form with time-animated stripe colors. The lava -// is a DONUT — no geometry sits under the main ground plane, so tile-edge -// gaps in the ground rasterization can't expose lava. Only rendered outside -// the arena bounds where it's actually visible from the pit. +// Build a fresh lava floor Form with time-animated stripe colors. Now that +// the Z-buffer correctly occludes, the lava is a FULL plane covering the +// entire pit area — no donut cut-out needed. The ground depth-tests above it. +// When perfLowMode is on, we build the lava once and cache it (no animation). +let lavaCache = null; +let lavaCacheFrame = -1; + function buildLavaFloor(t) { if (!FormRef) return null; + // When perf mode is low, return a cached static lava (skip stripe animation). + if (perfLowMode && lavaCache) return lavaCache; + const positions = []; const colors = []; const deathY = DEATH_FLOOR_Y; - - const lavaTile = (x0, z0, x1, z1) => { - const cx = (x0 + x1) / 2; - const cz = (z0 + z1) / 2; - const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); - const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); - const glow = (w1 + w2 * 0.6) * 0.5; - const hot = 0.35 + Math.max(0, glow) * 0.65; - const r = 0.45 + hot * 0.55; - const g = hot * hot * 0.45; - const b = hot * hot * hot * 0.08; - const c = [r, g, b, 1.0]; - positions.push( - [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], - [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], - ); - for (let i = 0; i < 6; i++) colors.push(c); - }; - - // 4 strips forming a frame around the main arena. Each strip gets its own - // local tessellation density so the stripes still look organic. - const STEP = 4; // lava tile size (AC units) - const gs = GROUND_SIZE; - // North strip: z in [gs, DEATH_PAD], x in [-DEATH_PAD, DEATH_PAD] - for (let z = gs; z < DEATH_PAD; z += STEP) { - for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, DEATH_PAD)); - } - } - // South strip - for (let z = -DEATH_PAD; z < -gs; z += STEP) { + const STEP = perfLowMode ? 8 : 4; // coarser tessellation in low-perf mode + for (let z = -DEATH_PAD; z < DEATH_PAD; z += STEP) { for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, -gs)); - } - } - // West strip (within arena's Z range) - for (let x = -DEATH_PAD; x < -gs; x += STEP) { - for (let z = -gs; z < gs; z += STEP) { - lavaTile(x, z, Math.min(x + STEP, -gs), Math.min(z + STEP, gs)); - } - } - // East strip - for (let x = gs; x < DEATH_PAD; x += STEP) { - for (let z = -gs; z < gs; z += STEP) { - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, gs)); + const x0 = x, z0 = z; + const x1 = Math.min(x + STEP, DEATH_PAD); + const z1 = Math.min(z + STEP, DEATH_PAD); + const cx = (x0 + x1) / 2; + const cz = (z0 + z1) / 2; + const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); + const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); + const glow = (w1 + w2 * 0.6) * 0.5; + const hot = 0.35 + Math.max(0, glow) * 0.65; + const r = 0.45 + hot * 0.55; + const g = hot * hot * 0.45; + const b = hot * hot * hot * 0.08; + const c = [r, g, b, 1.0]; + positions.push( + [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], + [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], + ); + for (let i = 0; i < 6; i++) colors.push(c); } } - const f = new FormRef( { type: "triangle", positions, colors }, { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, ); f.noFade = true; + if (perfLowMode) lavaCache = f; return f; } @@ -242,6 +247,35 @@ function boot({ Form, penLock, system }) { ); groundPlane.noFade = true; + // 🔲 Ground skirt — a single opaque dark quad at the EXACT SAME Y as the + // main ground plane, covering a slightly oversized XZ footprint. Drawn + // BEFORE the ground so any rasterizer seams between ground tiles (painter's + // order, no depth buffer) reveal the dark skirt instead of the lava far + // below. The +0.5 AC-unit pad ensures the skirt also catches seams at the + // platform outer edge. + // Skirt sits 0.02 below the ground so painter's order places ground clearly + // on top; oversized by 0.5 AC units on each side so the outer edge of the + // arena also has a backstop. + const skirtY = GROUND_Y - 0.02; + const skirtR = GROUND_SIZE + 0.5; + const skirtColor = [0.06, 0.06, 0.08, 1.0]; + groundSkirt = new Form( + { + type: "triangle", + positions: [ + [-skirtR, skirtY, -skirtR, 1], + [-skirtR, skirtY, skirtR, 1], + [ skirtR, skirtY, skirtR, 1], + [-skirtR, skirtY, -skirtR, 1], + [ skirtR, skirtY, skirtR, 1], + [ skirtR, skirtY, -skirtR, 1], + ], + colors: [skirtColor, skirtColor, skirtColor, skirtColor, skirtColor, skirtColor], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + groundSkirt.noFade = true; + // 🧱 Platform edge — a glowing rim so you can see where the floor ends. // Four line segments slightly above the ground, plus short drops at each // corner so the edge reads in depth even when approaching at a shallow @@ -418,7 +452,7 @@ function boot({ Form, penLock, system }) { bodyArms.noFade = true; } -function sim({ system }) { +function sim({ system, pen, screen }) { const doll = system?.fps?.doll; const cam = doll?.cam; if (!cam) return; @@ -474,23 +508,48 @@ function sim({ system }) { else walkedTiles.set(k, next); } - // --- Hover tile: raycast camera-forward onto the ground plane. --- - // Forward in world (rotY=0 → +Z). rotX positive = look up, so forward.y - // = sin(rotX). If forward.y >= 0 we aren't looking at the floor. + // --- Hover tile: fire a proper mouse ray from the pen's screen pixel into + // the 3D scene, then intersect with the ground plane. When pen-locked the + // pen stops updating so we fall back to screen-centre (the crosshair). --- + // + // Pipeline: screen (px,py) → NDC → camera-space ray dir (u,v,1) → rotate + // by camera orientation → world ray → plane intersect. const rx = cam.rotX * Math.PI / 180; const ry = cam.rotY * Math.PI / 180; - const cosPitch = Math.cos(rx); - const fx = Math.sin(ry) * cosPitch; - const fy = Math.sin(rx); - const fz = Math.cos(ry) * cosPitch; + const sinRotX = Math.sin(rx), cosRotX = Math.cos(rx); + const sinRotY = Math.sin(ry), cosRotY = Math.cos(ry); + + // Use pen position when available, otherwise centre of screen. + const sw = screen?.width ?? 1, sh = screen?.height ?? 1; + const mx = penLocked ? sw / 2 : (pen?.x ?? sw / 2); + const my = penLocked ? sh / 2 : (pen?.y ?? sh / 2); + const ndcX = (2 * mx) / sw - 1; + const ndcY = 1 - (2 * my) / sh; + const tanHalfFov = Math.tan((FOV * Math.PI / 180) / 2); + const aspect = sw / sh; + // Camera-space ray direction at this screen pixel (before rotating). + const u = ndcX * aspect * tanHalfFov; + const v = ndcY * tanHalfFov; + // Rotate camera-space (u, v, 1) to world using (rotY(+rotY) ∘ rotX(-rotX)). + const fx = u * cosRotY - v * sinRotY * sinRotX + sinRotY * cosRotX; + const fy = v * cosRotX + sinRotX; + const fz = -u * sinRotY - v * cosRotY * sinRotX + cosRotY * cosRotX; + hoverTile = null; - if (fy < -0.05) { + lastHitWorld = null; + lastPenScreen = penLocked ? null : [mx, my]; + if (fy < -0.001) { + // Ray origin = render camera world position (not the logical player, so + // the hit point matches what the user sees through the crosshair/mouse). + const camWorldX = -cam.x; const camWorldY = -cam.y; + const camWorldZ = -cam.z; const t = (GROUND_Y - camWorldY) / fy; if (t > 0 && t < 200) { - const hitX = pWorldX + t * fx; - const hitZ = pWorldZ + t * fz; + const hitX = camWorldX + t * fx; + const hitZ = camWorldZ + t * fz; hoverTile = tileAt(hitX, hitZ); + lastHitWorld = [hitX, hitZ]; } } @@ -530,9 +589,51 @@ function sim({ system }) { bodyArms.position[2] = playerCamZ; bodyArms.rotation[1] = cam.rotY; } + + // 🐛 Once-per-second debug dump. Paste this back into chat to diagnose. + debugDumpTimer += 1; + if (debugDumpTimer >= DEBUG_DUMP_INTERVAL) { + debugDumpTimer = 0; + const f2 = (n) => (typeof n === "number" ? n.toFixed(2) : String(n)); + const hoverStr = hoverTile + ? `${hoverTile.row},${hoverTile.col}` + : "none"; + const penStr = pen + ? `pen=(${f2(pen.x)}, ${f2(pen.y)})` + : "pen=null"; + const hitStr = lastHitWorld + ? `(${f2(lastHitWorld[0])}, ${f2(lastHitWorld[1])})` + : "none"; + console.log( + "🏟️ arena snapshot:", + JSON.stringify({ + player: { x: f2(pWorldX), y: f2(pWorldY), z: f2(pWorldZ) }, + cam: { + x: f2(cam.x), y: f2(cam.y), z: f2(cam.z), + rotX: f2(cam.rotX), rotY: f2(cam.rotY), + }, + phys: phys ? { + onGround: phys.onGround, + crouch: f2(phys.crouch), + yVel: f2(phys.worldYVel), + thirdPerson: phys.thirdPerson, + } : null, + state: { alive: playerAlive, penLocked, walkPhase: f2(walkPhase) }, + tiles: { + current: prevPlayerTile, + hover: hoverStr, + hitWorldXZ: hitStr, + trailCount: walkedTiles.size, + }, + mouse: `${penStr} screen=${f2(screen?.width)}x${f2(screen?.height)}`, + rayDir: `fx=${f2(fx)} fy=${f2(fy)} fz=${f2(fz)}`, + speed: f2(speedSmoothed * SIM_HZ) + " u/s", + }, null, 2), + ); + } } -function paint({ wipe, ink, screen, write, box, system }) { +function paint({ wipe, ink, screen, write, box, system, pen }) { // FPS calc const now = performance.now(); const dt = now - lastFrameTime; @@ -542,6 +643,23 @@ function paint({ wipe, ink, screen, write, box, system }) { const avgDt = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; const fps = Math.round(1000 / avgDt); + // ⚡ Adaptive quality — switch modes with hysteresis so we don't flip every + // frame. Require several samples of sustained FPS before changing state. + perfSamplesSinceSwitch += 1; + if (perfSamplesSinceSwitch > 30) { + if (avgDt > PERF_LOW_MS && !perfLowMode) { + perfLowMode = true; perfMedMode = true; perfSamplesSinceSwitch = 0; + lavaCache = null; // force rebuild at low tessellation + } else if (avgDt > PERF_MED_MS && !perfMedMode) { + perfMedMode = true; perfSamplesSinceSwitch = 0; + } else if (avgDt < PERF_HIGH_MS && perfLowMode) { + perfLowMode = false; perfSamplesSinceSwitch = 0; + lavaCache = null; // rebuild each frame again + } else if (avgDt < PERF_HIGH_MS && perfMedMode) { + perfMedMode = false; perfSamplesSinceSwitch = 0; + } + } + // --- Tile highlights: build a single transient Form containing one quad // per visible highlight (hover + walked trail). Drawn *after* the ground, // just above floor-Y, with additive-ish semi-transparent tint. @@ -575,10 +693,12 @@ function paint({ wipe, ink, screen, write, box, system }) { } // Render scene — lava donut first (never under the main ground), then the - // ground, its glowing edge, tile highlights, then the feet shadow + body. + // dark skirt that seals any tile-seam gaps, then the ground, its glowing + // edge, tile highlights, feet shadow + body. wipe(45, 48, 55); const lava = buildLavaFloor(now / 1000); if (lava) ink(255).form(lava); + if (groundSkirt) ink(255).form(groundSkirt); ink(255).form(groundPlane); if (platformEdge) ink(255).form(platformEdge); if (hiPos.length > 0 && FormRef) { @@ -604,8 +724,11 @@ function paint({ wipe, ink, screen, write, box, system }) { ink(255, 255, 255).form(plumbLine); } // Feet + arms render regardless of ground state (they fall with you). - if (bodyFeet) ink(255).form(bodyFeet); - if (bodyArms) ink(255).form(bodyArms); + // Dropped entirely in LOW perf mode — wireframes are nice-to-have. + if (!perfLowMode) { + if (bodyFeet) ink(255).form(bodyFeet); + if (bodyArms) ink(255).form(bodyArms); + } // --- HUD (top-right) --- const font = "MatrixChunky8"; @@ -645,6 +768,18 @@ function paint({ wipe, ink, screen, write, box, system }) { rightLabel(phys.thirdPerson ? "3P" : "1P", margin + lineH * 5); } + // ⚡ Perf mode label — shows which adaptive-quality tier is active. Flashes + // briefly after a tier change (perfSamplesSinceSwitch < 6). + const perfLabel = perfLowMode ? "PERF LOW" : perfMedMode ? "PERF MED" : "PERF HIGH"; + const perfFresh = perfSamplesSinceSwitch < 6; + ink(perfLowMode ? "red" : perfMedMode ? "orange" : "lime"); + if (perfFresh) ink("white"); + rightLabel(perfLabel, margin + lineH * 6); + // Show which features are currently disabled by the perf tier. + ink(150, 150, 150); + if (perfLowMode) rightLabel("-BODY -ANIM", margin + lineH * 7); + else if (perfMedMode) rightLabel("-LAVA-ANIM", margin + lineH * 7); + // Speed meter (bottom-center). speedSmoothed is per-sim-tick position delta; // sim runs at SIM_HZ, so ups = perTickDelta * SIM_HZ. const ups = speedSmoothed * SIM_HZ; @@ -667,6 +802,16 @@ function paint({ wipe, ink, screen, write, box, system }) { ink("white"); write(`${ups.toFixed(1)} u/s`, { x: barX + barW + 4, y: barY - 1 }, undefined, undefined, false, font); + // 🎯 Debug crosshair at the current pen position (unlocked mode only) so + // we can visually compare it against the highlighted hover tile. + if (pen && !penLocked) { + const px = Math.floor(pen.x); + const py = Math.floor(pen.y); + ink("magenta"); + box(px - 6, py, 13, 1); + box(px, py - 6, 1, 13); + } + // --- 💀 Death screen overlay (fade-in) --- if (!playerAlive) { const fade = Math.min(1, deathTickAge / 24); // ~0.2 s ramp diff --git a/system/public/aesthetic.computer/lib/graph.mjs b/system/public/aesthetic.computer/lib/graph.mjs index cf0b661fc2..16535f2558 100644 --- a/system/public/aesthetic.computer/lib/graph.mjs +++ b/system/public/aesthetic.computer/lib/graph.mjs @@ -35,7 +35,22 @@ import { nanoid } from "../dep/nanoid/nanoid.js"; const { round, sign: mathSign, abs, ceil, floor, sin, cos, min, max, sqrt, PI } = Math; let width, height, pixels; +// 🎬 Classic software-renderer Z-buffer. Holds per-pixel depth in NDC Z space +// (after perspective divide). Convention: lower = nearer, higher = farther. +// `Number.MAX_VALUE` is the "infinitely far" sentinel so any real geometry +// passes the first draw. Kept as a plain Array (not typed) because disk.mjs +// resizes it with `depthBuffer.length = W*H` which only works on Arrays. const depthBuffer = []; +let depthEnabled = true; // global toggle (pieces can flip this for debugging) + +function clearDepthBuffer() { + if (!depthEnabled) return; + const needed = width * height; + if (needed === 0) return; + if (depthBuffer.length !== needed) depthBuffer.length = needed; + depthBuffer.fill(Number.MAX_VALUE); +} + const writeBuffer = []; const c = [255, 255, 255, 255]; let c2 = null; // Alternate / secondary color support. @@ -1391,7 +1406,13 @@ function calculateAngleFadePosition(x, y, minX, minY, maxX, maxY, angle) { // 2. 2D Drawing function clear() { // 🚀 OPTIMIZED CLEAR FUNCTION - Up to 10x faster! - + + // 🎬 Classic Z-buffer: reset to +Infinity at the start of each frame so + // subsequent 3D form draws correctly occlude each other regardless of the + // order they're submitted in. Matches the width × height of the current + // screen; grows/shrinks on resolution change. + clearDepthBuffer(); + // Clean up blur buffers to prevent memory accumulation cleanupBlurBuffers(); @@ -4099,18 +4120,18 @@ function drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, co // Check if point is inside triangle if (u >= 0 && v >= 0 && w >= 0) { - // Interpolate Z value for depth testing - const interpZ = u * z1 + v * z2 + w * z3; - const depth = interpZ * -1; - - // Depth test - skip this pixel if it's behind existing geometry + // Interpolate NDC Z for depth testing. Convention: lower Z = nearer + // (post-perspective-divide Z is in [-1, +1]; near plane → -1). + const depth = u * z1 + v * z2 + w * z3; + + // Z-test: skip this pixel if a nearer fragment is already there. const bufferIndex = x + y * width; - if (depthBuffer && depthBuffer.length > 0) { + if (depthBuffer.length > 0) { if (depth > depthBuffer[bufferIndex]) { - continue; // Skip this pixel - behind existing geometry + continue; // existing pixel is closer to the camera } } - + renderStats.pixelsDrawn++; // Interpolate colors using barycentric weights @@ -4120,7 +4141,7 @@ function drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, co const a = floor(u * color1[3] + v * color2[3] + w * color3[3]); // Update depth buffer - if (depthBuffer && depthBuffer.length > 0) { + if (depthBuffer.length > 0) { depthBuffer[bufferIndex] = depth; } @@ -4210,11 +4231,11 @@ function subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, // Each vertex has UV coordinates that map to a texture buffer // Uses perspective-correct interpolation with clip-space W values (not NDC Z!) // Also interpolates Z values for depth buffering -function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { - // Safety check: avoid division by zero or near-zero W values. This is stricter - // than "finite math only" but much looser than the clip-space near plane so - // near-camera floor geometry can survive clipping without exploding. - if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { +function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { + // Safety check: avoid division by zero or near-zero W values. This is stricter + // than "finite math only" but much looser than the clip-space near plane so + // near-camera floor geometry can survive clipping without exploding. + if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { return; // Skip triangles with invalid depth } @@ -4285,18 +4306,18 @@ function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, // Check if point is inside triangle if (u >= 0 && v >= 0 && w >= 0) { - // Interpolate Z value for depth testing - const interpZ = u * z1 + v * z2 + w * z3; - const depth = interpZ * -1; - - // Depth test - skip this pixel if it's behind existing geometry + // Interpolate NDC Z for depth testing. Convention: lower Z = nearer + // (post-perspective-divide Z is in [-1, +1]; near plane → -1). + const depth = u * z1 + v * z2 + w * z3; + + // Z-test: skip this pixel if a nearer fragment is already there. const bufferIndex = x + y * width; - if (depthBuffer && depthBuffer.length > 0) { + if (depthBuffer.length > 0) { if (depth > depthBuffer[bufferIndex]) { - continue; // Skip this pixel - behind existing geometry + continue; // existing pixel is closer to the camera } } - + renderStats.pixelsDrawn++; // Interpolate 1/w and uv/w using barycentric weights @@ -4328,7 +4349,7 @@ function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, const a = floor(texture.pixels[pixelIndex + 3] * alphaMultiplier); // Update depth buffer and draw pixel - if (depthBuffer && depthBuffer.length > 0) { + if (depthBuffer.length > 0) { depthBuffer[bufferIndex] = depth; } @@ -8610,19 +8631,19 @@ class Form { const transformedA = a.transform(fullMatrix); const transformedB = b.transform(fullMatrix); - const clippedLine = clipLineToFrustum(transformedA, transformedB); - if (clippedLine.length < 2) continue; - const [lineA, lineB] = clippedLine; - - // Apply perspective divide and screen space transformation - const screenA = toScreenSpace(perspectiveDivide(lineA)); - const screenB = toScreenSpace(perspectiveDivide(lineB)); - if ( - !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || - !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) - ) { - continue; - } + const clippedLine = clipLineToFrustum(transformedA, transformedB); + if (clippedLine.length < 2) continue; + const [lineA, lineB] = clippedLine; + + // Apply perspective divide and screen space transformation + const screenA = toScreenSpace(perspectiveDivide(lineA)); + const screenB = toScreenSpace(perspectiveDivide(lineB)); + if ( + !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || + !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) + ) { + continue; + } const clipped = clipLineToScreen( screenA.pos[0], @@ -8693,9 +8714,9 @@ class Form { // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the // projection's W_clip = Z_view convention) can leave a vertex just under. let anyWTooSmall = false; - for (let vi = 0; vi < clippedVertices.length; vi++) { - if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } - } + for (let vi = 0; vi < clippedVertices.length; vi++) { + if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } + } if (anyWTooSmall) continue; // Count clipped triangle (only if it resulted in a polygon) @@ -8931,8 +8952,8 @@ class Vertex { // post-clip, and must be comfortably above 0 so perspective divide stays stable. // Too small → near-camera triangles "shoot off" the screen. // Too large → noticeable geometry popping right in front of the camera. -const NEAR_CLIP_Z = 0.02; -const MIN_PERSPECTIVE_W = 0.001; +const NEAR_CLIP_Z = 0.02; +const MIN_PERSPECTIVE_W = 0.001; function clipInClipSpace(vertices, clippingBoundary) { let clipped = []; @@ -9138,13 +9159,13 @@ function clipLineToFrustum(v1, v2) { return p[X] <= p[W]; case "bottom": return p[Y] >= -p[W]; - case "top": - return p[Y] <= p[W]; - case "near": - return p[Z] >= NEAR_CLIP_Z; - case "far": - return p[Z] <= p[W]; - } + case "top": + return p[Y] <= p[W]; + case "near": + return p[Z] >= NEAR_CLIP_Z; + case "far": + return p[Z] <= p[W]; + } } function computeIntersection(p1, p2, edge) { @@ -9159,22 +9180,22 @@ function clipLineToFrustum(v1, v2) { case "bottom": t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); break; - case "top": - t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); - break; - case "near": - t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); - break; - case "far": - t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); - break; - } - - if (!Number.isFinite(t)) t = 0.5; - t = max(0, min(1, t)); - - return t; - } + case "top": + t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); + break; + case "near": + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); + break; + case "far": + t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); + break; + } + + if (!Number.isFinite(t)) t = 0.5; + t = max(0, min(1, t)); + + return t; + } let clippedVertices = [v1, v2]; @@ -9192,22 +9213,22 @@ function clipLineToFrustum(v1, v2) { // Both outside, discard both clippedVertices = []; break; - } else { - // One inside, one outside - const t = computeIntersection(p1.pos, p2.pos, edge); - const intersectionVertex = new Vertex( - vec4.lerp(vec4.create(), p1.pos, p2.pos, t), - [ - p1.color[0] + (p2.color[0] - p1.color[0]) * t, - p1.color[1] + (p2.color[1] - p1.color[1]) * t, - p1.color[2] + (p2.color[2] - p1.color[2]) * t, - p1.color[3] + (p2.color[3] - p1.color[3]) * t, - ], - [ - p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, - p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, - ], - ); + } else { + // One inside, one outside + const t = computeIntersection(p1.pos, p2.pos, edge); + const intersectionVertex = new Vertex( + vec4.lerp(vec4.create(), p1.pos, p2.pos, t), + [ + p1.color[0] + (p2.color[0] - p1.color[0]) * t, + p1.color[1] + (p2.color[1] - p1.color[1]) * t, + p1.color[2] + (p2.color[2] - p1.color[2]) * t, + p1.color[3] + (p2.color[3] - p1.color[3]) * t, + ], + [ + p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, + p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, + ], + ); if (p1Inside) { // p1 inside, p2 outside @@ -9222,10 +9243,10 @@ function clipLineToFrustum(v1, v2) { return clippedVertices; } -function perspectiveDivide(vertex) { - // Safety check: avoid division by very small W values - const w = vertex.pos[W]; - if (abs(w) < MIN_PERSPECTIVE_W) { +function perspectiveDivide(vertex) { + // Safety check: avoid division by very small W values + const w = vertex.pos[W]; + if (abs(w) < MIN_PERSPECTIVE_W) { // Return a vertex marked as invalid (will be filtered out) const vert = new Vertex([NaN, NaN, NaN, w]); vert.color = vertex.color; @@ -9321,11 +9342,11 @@ function lerpVertex(v1, v2, t) { const w2 = v2.pos[3]; // Fall back to simple linear interpolation if W values are degenerate - const safeW = - w1 > MIN_PERSPECTIVE_W && - w2 > MIN_PERSPECTIVE_W && - Number.isFinite(w1) && - Number.isFinite(w2); + const safeW = + w1 > MIN_PERSPECTIVE_W && + w2 > MIN_PERSPECTIVE_W && + Number.isFinite(w1) && + Number.isFinite(w2); let newW; if (safeW) { -- 2.51.2