Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

arena: Z-buffer + adaptive perf, lava under whole arena, debug overlay

- 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) <noreply@anthropic.com>

+320 -154
+209 -64
system/public/aesthetic.computer/disks/arena.mjs
··· 12 12 #endregion */ 13 13 14 14 let groundPlane; 15 + let groundSkirt; // solid opaque plate just under the ground that blocks 16 + // any lava bleed-through between ground tile seams 15 17 let shadowGround; // ring + cross — standing on the ground 16 18 let shadowAir; // diagonal X — airborne 17 19 let shadowCrouch; // dense inner dot + outer ring — crouched ··· 25 27 // Walk-cycle phase (advanced in sim while moving) for gentle arm/foot bob. 26 28 let walkPhase = 0; 27 29 30 + // 🐛 Debug: dump a snapshot of scene state once per second so it can be 31 + // pasted back verbatim when something looks off. 32 + let debugDumpTimer = 0; 33 + const DEBUG_DUMP_INTERVAL = 120; // sim ticks (= 1 s at SIM_HZ=120) 34 + 28 35 // 💀 Death / respawn state 29 36 let playerAlive = true; 30 37 let deathTickAge = 0; // how long we've been dead (sim ticks, for UI fade-in) ··· 37 44 let hoverTile = null; 38 45 let prevPlayerTile = null; 39 46 const walkedTiles = new Map(); 47 + 48 + // 🔍 Diagnostic: stash the last hit point + pen coords so paint() can draw a 49 + // visible crosshair and the snapshot can log exactly where the ray landed. 50 + let lastHitWorld = null; // [x, z] or null 51 + let lastPenScreen = null; // [x, y] or null 52 + 53 + // ⚡ Adaptive-quality flags driven by measured render FPS. Auto-toggle in 54 + // paint() based on the rolling frame-time average. Pieces can override via the 55 + // HUD labels (future: click to pin). "LOW" = coarser tile, static lava, skip 56 + // body wireframes. "MED" = static lava only. "HIGH" = everything on. 57 + let perfLowMode = false; 58 + let perfMedMode = false; 59 + const PERF_LOW_MS = 25; // below ~40fps → drop to LOW 60 + const PERF_MED_MS = 18; // below ~55fps → drop to MED 61 + const PERF_HIGH_MS = 14; // above ~70fps → return to HIGH 62 + let perfSamplesSinceSwitch = 0; 40 63 41 64 function tileKey(row, col) { return row * GRID + col; } 42 65 function tileFromKey(k) { return { row: Math.floor(k / GRID), col: k % GRID }; } ··· 120 143 const COLOR_A = [0.38, 0.35, 0.30, 1.0]; 121 144 const COLOR_B = [0.22, 0.20, 0.19, 1.0]; 122 145 123 - // Build a fresh lava floor Form with time-animated stripe colors. The lava 124 - // is a DONUT — no geometry sits under the main ground plane, so tile-edge 125 - // gaps in the ground rasterization can't expose lava. Only rendered outside 126 - // the arena bounds where it's actually visible from the pit. 146 + // Build a fresh lava floor Form with time-animated stripe colors. Now that 147 + // the Z-buffer correctly occludes, the lava is a FULL plane covering the 148 + // entire pit area — no donut cut-out needed. The ground depth-tests above it. 149 + // When perfLowMode is on, we build the lava once and cache it (no animation). 150 + let lavaCache = null; 151 + let lavaCacheFrame = -1; 152 + 127 153 function buildLavaFloor(t) { 128 154 if (!FormRef) return null; 155 + // When perf mode is low, return a cached static lava (skip stripe animation). 156 + if (perfLowMode && lavaCache) return lavaCache; 157 + 129 158 const positions = []; 130 159 const colors = []; 131 160 const deathY = DEATH_FLOOR_Y; 132 - 133 - const lavaTile = (x0, z0, x1, z1) => { 134 - const cx = (x0 + x1) / 2; 135 - const cz = (z0 + z1) / 2; 136 - const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); 137 - const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); 138 - const glow = (w1 + w2 * 0.6) * 0.5; 139 - const hot = 0.35 + Math.max(0, glow) * 0.65; 140 - const r = 0.45 + hot * 0.55; 141 - const g = hot * hot * 0.45; 142 - const b = hot * hot * hot * 0.08; 143 - const c = [r, g, b, 1.0]; 144 - positions.push( 145 - [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], 146 - [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], 147 - ); 148 - for (let i = 0; i < 6; i++) colors.push(c); 149 - }; 150 - 151 - // 4 strips forming a frame around the main arena. Each strip gets its own 152 - // local tessellation density so the stripes still look organic. 153 - const STEP = 4; // lava tile size (AC units) 154 - const gs = GROUND_SIZE; 155 - // North strip: z in [gs, DEATH_PAD], x in [-DEATH_PAD, DEATH_PAD] 156 - for (let z = gs; z < DEATH_PAD; z += STEP) { 161 + const STEP = perfLowMode ? 8 : 4; // coarser tessellation in low-perf mode 162 + for (let z = -DEATH_PAD; z < DEATH_PAD; z += STEP) { 157 163 for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { 158 - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, DEATH_PAD)); 159 - } 160 - } 161 - // South strip 162 - for (let z = -DEATH_PAD; z < -gs; z += STEP) { 163 - for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { 164 - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, -gs)); 165 - } 166 - } 167 - // West strip (within arena's Z range) 168 - for (let x = -DEATH_PAD; x < -gs; x += STEP) { 169 - for (let z = -gs; z < gs; z += STEP) { 170 - lavaTile(x, z, Math.min(x + STEP, -gs), Math.min(z + STEP, gs)); 171 - } 172 - } 173 - // East strip 174 - for (let x = gs; x < DEATH_PAD; x += STEP) { 175 - for (let z = -gs; z < gs; z += STEP) { 176 - lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, gs)); 164 + const x0 = x, z0 = z; 165 + const x1 = Math.min(x + STEP, DEATH_PAD); 166 + const z1 = Math.min(z + STEP, DEATH_PAD); 167 + const cx = (x0 + x1) / 2; 168 + const cz = (z0 + z1) / 2; 169 + const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); 170 + const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); 171 + const glow = (w1 + w2 * 0.6) * 0.5; 172 + const hot = 0.35 + Math.max(0, glow) * 0.65; 173 + const r = 0.45 + hot * 0.55; 174 + const g = hot * hot * 0.45; 175 + const b = hot * hot * hot * 0.08; 176 + const c = [r, g, b, 1.0]; 177 + positions.push( 178 + [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], 179 + [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], 180 + ); 181 + for (let i = 0; i < 6; i++) colors.push(c); 177 182 } 178 183 } 179 - 180 184 const f = new FormRef( 181 185 { type: "triangle", positions, colors }, 182 186 { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 183 187 ); 184 188 f.noFade = true; 189 + if (perfLowMode) lavaCache = f; 185 190 return f; 186 191 } 187 192 ··· 241 246 { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 242 247 ); 243 248 groundPlane.noFade = true; 249 + 250 + // 🔲 Ground skirt — a single opaque dark quad at the EXACT SAME Y as the 251 + // main ground plane, covering a slightly oversized XZ footprint. Drawn 252 + // BEFORE the ground so any rasterizer seams between ground tiles (painter's 253 + // order, no depth buffer) reveal the dark skirt instead of the lava far 254 + // below. The +0.5 AC-unit pad ensures the skirt also catches seams at the 255 + // platform outer edge. 256 + // Skirt sits 0.02 below the ground so painter's order places ground clearly 257 + // on top; oversized by 0.5 AC units on each side so the outer edge of the 258 + // arena also has a backstop. 259 + const skirtY = GROUND_Y - 0.02; 260 + const skirtR = GROUND_SIZE + 0.5; 261 + const skirtColor = [0.06, 0.06, 0.08, 1.0]; 262 + groundSkirt = new Form( 263 + { 264 + type: "triangle", 265 + positions: [ 266 + [-skirtR, skirtY, -skirtR, 1], 267 + [-skirtR, skirtY, skirtR, 1], 268 + [ skirtR, skirtY, skirtR, 1], 269 + [-skirtR, skirtY, -skirtR, 1], 270 + [ skirtR, skirtY, skirtR, 1], 271 + [ skirtR, skirtY, -skirtR, 1], 272 + ], 273 + colors: [skirtColor, skirtColor, skirtColor, skirtColor, skirtColor, skirtColor], 274 + }, 275 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 276 + ); 277 + groundSkirt.noFade = true; 244 278 245 279 // 🧱 Platform edge — a glowing rim so you can see where the floor ends. 246 280 // Four line segments slightly above the ground, plus short drops at each ··· 418 452 bodyArms.noFade = true; 419 453 } 420 454 421 - function sim({ system }) { 455 + function sim({ system, pen, screen }) { 422 456 const doll = system?.fps?.doll; 423 457 const cam = doll?.cam; 424 458 if (!cam) return; ··· 474 508 else walkedTiles.set(k, next); 475 509 } 476 510 477 - // --- Hover tile: raycast camera-forward onto the ground plane. --- 478 - // Forward in world (rotY=0 → +Z). rotX positive = look up, so forward.y 479 - // = sin(rotX). If forward.y >= 0 we aren't looking at the floor. 511 + // --- Hover tile: fire a proper mouse ray from the pen's screen pixel into 512 + // the 3D scene, then intersect with the ground plane. When pen-locked the 513 + // pen stops updating so we fall back to screen-centre (the crosshair). --- 514 + // 515 + // Pipeline: screen (px,py) → NDC → camera-space ray dir (u,v,1) → rotate 516 + // by camera orientation → world ray → plane intersect. 480 517 const rx = cam.rotX * Math.PI / 180; 481 518 const ry = cam.rotY * Math.PI / 180; 482 - const cosPitch = Math.cos(rx); 483 - const fx = Math.sin(ry) * cosPitch; 484 - const fy = Math.sin(rx); 485 - const fz = Math.cos(ry) * cosPitch; 519 + const sinRotX = Math.sin(rx), cosRotX = Math.cos(rx); 520 + const sinRotY = Math.sin(ry), cosRotY = Math.cos(ry); 521 + 522 + // Use pen position when available, otherwise centre of screen. 523 + const sw = screen?.width ?? 1, sh = screen?.height ?? 1; 524 + const mx = penLocked ? sw / 2 : (pen?.x ?? sw / 2); 525 + const my = penLocked ? sh / 2 : (pen?.y ?? sh / 2); 526 + const ndcX = (2 * mx) / sw - 1; 527 + const ndcY = 1 - (2 * my) / sh; 528 + const tanHalfFov = Math.tan((FOV * Math.PI / 180) / 2); 529 + const aspect = sw / sh; 530 + // Camera-space ray direction at this screen pixel (before rotating). 531 + const u = ndcX * aspect * tanHalfFov; 532 + const v = ndcY * tanHalfFov; 533 + // Rotate camera-space (u, v, 1) to world using (rotY(+rotY) ∘ rotX(-rotX)). 534 + const fx = u * cosRotY - v * sinRotY * sinRotX + sinRotY * cosRotX; 535 + const fy = v * cosRotX + sinRotX; 536 + const fz = -u * sinRotY - v * cosRotY * sinRotX + cosRotY * cosRotX; 537 + 486 538 hoverTile = null; 487 - if (fy < -0.05) { 539 + lastHitWorld = null; 540 + lastPenScreen = penLocked ? null : [mx, my]; 541 + if (fy < -0.001) { 542 + // Ray origin = render camera world position (not the logical player, so 543 + // the hit point matches what the user sees through the crosshair/mouse). 544 + const camWorldX = -cam.x; 488 545 const camWorldY = -cam.y; 546 + const camWorldZ = -cam.z; 489 547 const t = (GROUND_Y - camWorldY) / fy; 490 548 if (t > 0 && t < 200) { 491 - const hitX = pWorldX + t * fx; 492 - const hitZ = pWorldZ + t * fz; 549 + const hitX = camWorldX + t * fx; 550 + const hitZ = camWorldZ + t * fz; 493 551 hoverTile = tileAt(hitX, hitZ); 552 + lastHitWorld = [hitX, hitZ]; 494 553 } 495 554 } 496 555 ··· 530 589 bodyArms.position[2] = playerCamZ; 531 590 bodyArms.rotation[1] = cam.rotY; 532 591 } 592 + 593 + // 🐛 Once-per-second debug dump. Paste this back into chat to diagnose. 594 + debugDumpTimer += 1; 595 + if (debugDumpTimer >= DEBUG_DUMP_INTERVAL) { 596 + debugDumpTimer = 0; 597 + const f2 = (n) => (typeof n === "number" ? n.toFixed(2) : String(n)); 598 + const hoverStr = hoverTile 599 + ? `${hoverTile.row},${hoverTile.col}` 600 + : "none"; 601 + const penStr = pen 602 + ? `pen=(${f2(pen.x)}, ${f2(pen.y)})` 603 + : "pen=null"; 604 + const hitStr = lastHitWorld 605 + ? `(${f2(lastHitWorld[0])}, ${f2(lastHitWorld[1])})` 606 + : "none"; 607 + console.log( 608 + "🏟️ arena snapshot:", 609 + JSON.stringify({ 610 + player: { x: f2(pWorldX), y: f2(pWorldY), z: f2(pWorldZ) }, 611 + cam: { 612 + x: f2(cam.x), y: f2(cam.y), z: f2(cam.z), 613 + rotX: f2(cam.rotX), rotY: f2(cam.rotY), 614 + }, 615 + phys: phys ? { 616 + onGround: phys.onGround, 617 + crouch: f2(phys.crouch), 618 + yVel: f2(phys.worldYVel), 619 + thirdPerson: phys.thirdPerson, 620 + } : null, 621 + state: { alive: playerAlive, penLocked, walkPhase: f2(walkPhase) }, 622 + tiles: { 623 + current: prevPlayerTile, 624 + hover: hoverStr, 625 + hitWorldXZ: hitStr, 626 + trailCount: walkedTiles.size, 627 + }, 628 + mouse: `${penStr} screen=${f2(screen?.width)}x${f2(screen?.height)}`, 629 + rayDir: `fx=${f2(fx)} fy=${f2(fy)} fz=${f2(fz)}`, 630 + speed: f2(speedSmoothed * SIM_HZ) + " u/s", 631 + }, null, 2), 632 + ); 633 + } 533 634 } 534 635 535 - function paint({ wipe, ink, screen, write, box, system }) { 636 + function paint({ wipe, ink, screen, write, box, system, pen }) { 536 637 // FPS calc 537 638 const now = performance.now(); 538 639 const dt = now - lastFrameTime; ··· 542 643 const avgDt = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; 543 644 const fps = Math.round(1000 / avgDt); 544 645 646 + // ⚡ Adaptive quality — switch modes with hysteresis so we don't flip every 647 + // frame. Require several samples of sustained FPS before changing state. 648 + perfSamplesSinceSwitch += 1; 649 + if (perfSamplesSinceSwitch > 30) { 650 + if (avgDt > PERF_LOW_MS && !perfLowMode) { 651 + perfLowMode = true; perfMedMode = true; perfSamplesSinceSwitch = 0; 652 + lavaCache = null; // force rebuild at low tessellation 653 + } else if (avgDt > PERF_MED_MS && !perfMedMode) { 654 + perfMedMode = true; perfSamplesSinceSwitch = 0; 655 + } else if (avgDt < PERF_HIGH_MS && perfLowMode) { 656 + perfLowMode = false; perfSamplesSinceSwitch = 0; 657 + lavaCache = null; // rebuild each frame again 658 + } else if (avgDt < PERF_HIGH_MS && perfMedMode) { 659 + perfMedMode = false; perfSamplesSinceSwitch = 0; 660 + } 661 + } 662 + 545 663 // --- Tile highlights: build a single transient Form containing one quad 546 664 // per visible highlight (hover + walked trail). Drawn *after* the ground, 547 665 // just above floor-Y, with additive-ish semi-transparent tint. ··· 575 693 } 576 694 577 695 // Render scene — lava donut first (never under the main ground), then the 578 - // ground, its glowing edge, tile highlights, then the feet shadow + body. 696 + // dark skirt that seals any tile-seam gaps, then the ground, its glowing 697 + // edge, tile highlights, feet shadow + body. 579 698 wipe(45, 48, 55); 580 699 const lava = buildLavaFloor(now / 1000); 581 700 if (lava) ink(255).form(lava); 701 + if (groundSkirt) ink(255).form(groundSkirt); 582 702 ink(255).form(groundPlane); 583 703 if (platformEdge) ink(255).form(platformEdge); 584 704 if (hiPos.length > 0 && FormRef) { ··· 604 724 ink(255, 255, 255).form(plumbLine); 605 725 } 606 726 // Feet + arms render regardless of ground state (they fall with you). 607 - if (bodyFeet) ink(255).form(bodyFeet); 608 - if (bodyArms) ink(255).form(bodyArms); 727 + // Dropped entirely in LOW perf mode — wireframes are nice-to-have. 728 + if (!perfLowMode) { 729 + if (bodyFeet) ink(255).form(bodyFeet); 730 + if (bodyArms) ink(255).form(bodyArms); 731 + } 609 732 610 733 // --- HUD (top-right) --- 611 734 const font = "MatrixChunky8"; ··· 645 768 rightLabel(phys.thirdPerson ? "3P" : "1P", margin + lineH * 5); 646 769 } 647 770 771 + // ⚡ Perf mode label — shows which adaptive-quality tier is active. Flashes 772 + // briefly after a tier change (perfSamplesSinceSwitch < 6). 773 + const perfLabel = perfLowMode ? "PERF LOW" : perfMedMode ? "PERF MED" : "PERF HIGH"; 774 + const perfFresh = perfSamplesSinceSwitch < 6; 775 + ink(perfLowMode ? "red" : perfMedMode ? "orange" : "lime"); 776 + if (perfFresh) ink("white"); 777 + rightLabel(perfLabel, margin + lineH * 6); 778 + // Show which features are currently disabled by the perf tier. 779 + ink(150, 150, 150); 780 + if (perfLowMode) rightLabel("-BODY -ANIM", margin + lineH * 7); 781 + else if (perfMedMode) rightLabel("-LAVA-ANIM", margin + lineH * 7); 782 + 648 783 // Speed meter (bottom-center). speedSmoothed is per-sim-tick position delta; 649 784 // sim runs at SIM_HZ, so ups = perTickDelta * SIM_HZ. 650 785 const ups = speedSmoothed * SIM_HZ; ··· 666 801 667 802 ink("white"); 668 803 write(`${ups.toFixed(1)} u/s`, { x: barX + barW + 4, y: barY - 1 }, undefined, undefined, false, font); 804 + 805 + // 🎯 Debug crosshair at the current pen position (unlocked mode only) so 806 + // we can visually compare it against the highlighted hover tile. 807 + if (pen && !penLocked) { 808 + const px = Math.floor(pen.x); 809 + const py = Math.floor(pen.y); 810 + ink("magenta"); 811 + box(px - 6, py, 13, 1); 812 + box(px, py - 6, 1, 13); 813 + } 669 814 670 815 // --- 💀 Death screen overlay (fade-in) --- 671 816 if (!playerAlive) {
+111 -90
system/public/aesthetic.computer/lib/graph.mjs
··· 35 35 const { round, sign: mathSign, abs, ceil, floor, sin, cos, min, max, sqrt, PI } = Math; 36 36 37 37 let width, height, pixels; 38 + // 🎬 Classic software-renderer Z-buffer. Holds per-pixel depth in NDC Z space 39 + // (after perspective divide). Convention: lower = nearer, higher = farther. 40 + // `Number.MAX_VALUE` is the "infinitely far" sentinel so any real geometry 41 + // passes the first draw. Kept as a plain Array (not typed) because disk.mjs 42 + // resizes it with `depthBuffer.length = W*H` which only works on Arrays. 38 43 const depthBuffer = []; 44 + let depthEnabled = true; // global toggle (pieces can flip this for debugging) 45 + 46 + function clearDepthBuffer() { 47 + if (!depthEnabled) return; 48 + const needed = width * height; 49 + if (needed === 0) return; 50 + if (depthBuffer.length !== needed) depthBuffer.length = needed; 51 + depthBuffer.fill(Number.MAX_VALUE); 52 + } 53 + 39 54 const writeBuffer = []; 40 55 const c = [255, 255, 255, 255]; 41 56 let c2 = null; // Alternate / secondary color support. ··· 1391 1406 // 2. 2D Drawing 1392 1407 function clear() { 1393 1408 // 🚀 OPTIMIZED CLEAR FUNCTION - Up to 10x faster! 1394 - 1409 + 1410 + // 🎬 Classic Z-buffer: reset to +Infinity at the start of each frame so 1411 + // subsequent 3D form draws correctly occlude each other regardless of the 1412 + // order they're submitted in. Matches the width × height of the current 1413 + // screen; grows/shrinks on resolution change. 1414 + clearDepthBuffer(); 1415 + 1395 1416 // Clean up blur buffers to prevent memory accumulation 1396 1417 cleanupBlurBuffers(); 1397 1418 ··· 4099 4120 4100 4121 // Check if point is inside triangle 4101 4122 if (u >= 0 && v >= 0 && w >= 0) { 4102 - // Interpolate Z value for depth testing 4103 - const interpZ = u * z1 + v * z2 + w * z3; 4104 - const depth = interpZ * -1; 4105 - 4106 - // Depth test - skip this pixel if it's behind existing geometry 4123 + // Interpolate NDC Z for depth testing. Convention: lower Z = nearer 4124 + // (post-perspective-divide Z is in [-1, +1]; near plane → -1). 4125 + const depth = u * z1 + v * z2 + w * z3; 4126 + 4127 + // Z-test: skip this pixel if a nearer fragment is already there. 4107 4128 const bufferIndex = x + y * width; 4108 - if (depthBuffer && depthBuffer.length > 0) { 4129 + if (depthBuffer.length > 0) { 4109 4130 if (depth > depthBuffer[bufferIndex]) { 4110 - continue; // Skip this pixel - behind existing geometry 4131 + continue; // existing pixel is closer to the camera 4111 4132 } 4112 4133 } 4113 - 4134 + 4114 4135 renderStats.pixelsDrawn++; 4115 4136 4116 4137 // Interpolate colors using barycentric weights ··· 4120 4141 const a = floor(u * color1[3] + v * color2[3] + w * color3[3]); 4121 4142 4122 4143 // Update depth buffer 4123 - if (depthBuffer && depthBuffer.length > 0) { 4144 + if (depthBuffer.length > 0) { 4124 4145 depthBuffer[bufferIndex] = depth; 4125 4146 } 4126 4147 ··· 4210 4231 // Each vertex has UV coordinates that map to a texture buffer 4211 4232 // Uses perspective-correct interpolation with clip-space W values (not NDC Z!) 4212 4233 // Also interpolates Z values for depth buffering 4213 - function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { 4214 - // Safety check: avoid division by zero or near-zero W values. This is stricter 4215 - // than "finite math only" but much looser than the clip-space near plane so 4216 - // near-camera floor geometry can survive clipping without exploding. 4217 - if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { 4234 + function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { 4235 + // Safety check: avoid division by zero or near-zero W values. This is stricter 4236 + // than "finite math only" but much looser than the clip-space near plane so 4237 + // near-camera floor geometry can survive clipping without exploding. 4238 + if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { 4218 4239 return; // Skip triangles with invalid depth 4219 4240 } 4220 4241 ··· 4285 4306 4286 4307 // Check if point is inside triangle 4287 4308 if (u >= 0 && v >= 0 && w >= 0) { 4288 - // Interpolate Z value for depth testing 4289 - const interpZ = u * z1 + v * z2 + w * z3; 4290 - const depth = interpZ * -1; 4291 - 4292 - // Depth test - skip this pixel if it's behind existing geometry 4309 + // Interpolate NDC Z for depth testing. Convention: lower Z = nearer 4310 + // (post-perspective-divide Z is in [-1, +1]; near plane → -1). 4311 + const depth = u * z1 + v * z2 + w * z3; 4312 + 4313 + // Z-test: skip this pixel if a nearer fragment is already there. 4293 4314 const bufferIndex = x + y * width; 4294 - if (depthBuffer && depthBuffer.length > 0) { 4315 + if (depthBuffer.length > 0) { 4295 4316 if (depth > depthBuffer[bufferIndex]) { 4296 - continue; // Skip this pixel - behind existing geometry 4317 + continue; // existing pixel is closer to the camera 4297 4318 } 4298 4319 } 4299 - 4320 + 4300 4321 renderStats.pixelsDrawn++; 4301 4322 4302 4323 // Interpolate 1/w and uv/w using barycentric weights ··· 4328 4349 const a = floor(texture.pixels[pixelIndex + 3] * alphaMultiplier); 4329 4350 4330 4351 // Update depth buffer and draw pixel 4331 - if (depthBuffer && depthBuffer.length > 0) { 4352 + if (depthBuffer.length > 0) { 4332 4353 depthBuffer[bufferIndex] = depth; 4333 4354 } 4334 4355 ··· 8610 8631 const transformedA = a.transform(fullMatrix); 8611 8632 const transformedB = b.transform(fullMatrix); 8612 8633 8613 - const clippedLine = clipLineToFrustum(transformedA, transformedB); 8614 - if (clippedLine.length < 2) continue; 8615 - const [lineA, lineB] = clippedLine; 8616 - 8617 - // Apply perspective divide and screen space transformation 8618 - const screenA = toScreenSpace(perspectiveDivide(lineA)); 8619 - const screenB = toScreenSpace(perspectiveDivide(lineB)); 8620 - if ( 8621 - !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || 8622 - !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) 8623 - ) { 8624 - continue; 8625 - } 8634 + const clippedLine = clipLineToFrustum(transformedA, transformedB); 8635 + if (clippedLine.length < 2) continue; 8636 + const [lineA, lineB] = clippedLine; 8637 + 8638 + // Apply perspective divide and screen space transformation 8639 + const screenA = toScreenSpace(perspectiveDivide(lineA)); 8640 + const screenB = toScreenSpace(perspectiveDivide(lineB)); 8641 + if ( 8642 + !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || 8643 + !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) 8644 + ) { 8645 + continue; 8646 + } 8626 8647 8627 8648 const clipped = clipLineToScreen( 8628 8649 screenA.pos[0], ··· 8693 8714 // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the 8694 8715 // projection's W_clip = Z_view convention) can leave a vertex just under. 8695 8716 let anyWTooSmall = false; 8696 - for (let vi = 0; vi < clippedVertices.length; vi++) { 8697 - if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } 8698 - } 8717 + for (let vi = 0; vi < clippedVertices.length; vi++) { 8718 + if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } 8719 + } 8699 8720 if (anyWTooSmall) continue; 8700 8721 8701 8722 // Count clipped triangle (only if it resulted in a polygon) ··· 8931 8952 // post-clip, and must be comfortably above 0 so perspective divide stays stable. 8932 8953 // Too small → near-camera triangles "shoot off" the screen. 8933 8954 // Too large → noticeable geometry popping right in front of the camera. 8934 - const NEAR_CLIP_Z = 0.02; 8935 - const MIN_PERSPECTIVE_W = 0.001; 8955 + const NEAR_CLIP_Z = 0.02; 8956 + const MIN_PERSPECTIVE_W = 0.001; 8936 8957 8937 8958 function clipInClipSpace(vertices, clippingBoundary) { 8938 8959 let clipped = []; ··· 9138 9159 return p[X] <= p[W]; 9139 9160 case "bottom": 9140 9161 return p[Y] >= -p[W]; 9141 - case "top": 9142 - return p[Y] <= p[W]; 9143 - case "near": 9144 - return p[Z] >= NEAR_CLIP_Z; 9145 - case "far": 9146 - return p[Z] <= p[W]; 9147 - } 9162 + case "top": 9163 + return p[Y] <= p[W]; 9164 + case "near": 9165 + return p[Z] >= NEAR_CLIP_Z; 9166 + case "far": 9167 + return p[Z] <= p[W]; 9168 + } 9148 9169 } 9149 9170 9150 9171 function computeIntersection(p1, p2, edge) { ··· 9159 9180 case "bottom": 9160 9181 t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); 9161 9182 break; 9162 - case "top": 9163 - t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); 9164 - break; 9165 - case "near": 9166 - t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); 9167 - break; 9168 - case "far": 9169 - t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); 9170 - break; 9171 - } 9172 - 9173 - if (!Number.isFinite(t)) t = 0.5; 9174 - t = max(0, min(1, t)); 9175 - 9176 - return t; 9177 - } 9183 + case "top": 9184 + t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); 9185 + break; 9186 + case "near": 9187 + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); 9188 + break; 9189 + case "far": 9190 + t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); 9191 + break; 9192 + } 9193 + 9194 + if (!Number.isFinite(t)) t = 0.5; 9195 + t = max(0, min(1, t)); 9196 + 9197 + return t; 9198 + } 9178 9199 9179 9200 let clippedVertices = [v1, v2]; 9180 9201 ··· 9192 9213 // Both outside, discard both 9193 9214 clippedVertices = []; 9194 9215 break; 9195 - } else { 9196 - // One inside, one outside 9197 - const t = computeIntersection(p1.pos, p2.pos, edge); 9198 - const intersectionVertex = new Vertex( 9199 - vec4.lerp(vec4.create(), p1.pos, p2.pos, t), 9200 - [ 9201 - p1.color[0] + (p2.color[0] - p1.color[0]) * t, 9202 - p1.color[1] + (p2.color[1] - p1.color[1]) * t, 9203 - p1.color[2] + (p2.color[2] - p1.color[2]) * t, 9204 - p1.color[3] + (p2.color[3] - p1.color[3]) * t, 9205 - ], 9206 - [ 9207 - p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, 9208 - p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, 9209 - ], 9210 - ); 9216 + } else { 9217 + // One inside, one outside 9218 + const t = computeIntersection(p1.pos, p2.pos, edge); 9219 + const intersectionVertex = new Vertex( 9220 + vec4.lerp(vec4.create(), p1.pos, p2.pos, t), 9221 + [ 9222 + p1.color[0] + (p2.color[0] - p1.color[0]) * t, 9223 + p1.color[1] + (p2.color[1] - p1.color[1]) * t, 9224 + p1.color[2] + (p2.color[2] - p1.color[2]) * t, 9225 + p1.color[3] + (p2.color[3] - p1.color[3]) * t, 9226 + ], 9227 + [ 9228 + p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, 9229 + p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, 9230 + ], 9231 + ); 9211 9232 9212 9233 if (p1Inside) { 9213 9234 // p1 inside, p2 outside ··· 9222 9243 return clippedVertices; 9223 9244 } 9224 9245 9225 - function perspectiveDivide(vertex) { 9226 - // Safety check: avoid division by very small W values 9227 - const w = vertex.pos[W]; 9228 - if (abs(w) < MIN_PERSPECTIVE_W) { 9246 + function perspectiveDivide(vertex) { 9247 + // Safety check: avoid division by very small W values 9248 + const w = vertex.pos[W]; 9249 + if (abs(w) < MIN_PERSPECTIVE_W) { 9229 9250 // Return a vertex marked as invalid (will be filtered out) 9230 9251 const vert = new Vertex([NaN, NaN, NaN, w]); 9231 9252 vert.color = vertex.color; ··· 9321 9342 const w2 = v2.pos[3]; 9322 9343 9323 9344 // Fall back to simple linear interpolation if W values are degenerate 9324 - const safeW = 9325 - w1 > MIN_PERSPECTIVE_W && 9326 - w2 > MIN_PERSPECTIVE_W && 9327 - Number.isFinite(w1) && 9328 - Number.isFinite(w2); 9345 + const safeW = 9346 + w1 > MIN_PERSPECTIVE_W && 9347 + w2 > MIN_PERSPECTIVE_W && 9348 + Number.isFinite(w1) && 9349 + Number.isFinite(w2); 9329 9350 9330 9351 let newW; 9331 9352 if (safeW) {