The canvas below shows eight triangles arranged in a scene. A BVH has been built over them โ a root box wraps everything, then two child boxes, each covering half the scene, and finally individual leaf boxes around each triangle.
<div class="controls">
<button id="shoot" type="button">Shoot ray</button>
<label class="toggle"><input type="checkbox" id="brute"> Brute force</label>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="c" width="560" height="320"></canvas>
<div class="stats" id="stats">Press "Shoot ray" to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.toggle { display: flex; align-items: center; gap: .35rem; font: 600 13px system-ui;
user-select: none; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; display: block; max-width: 100%; background: #f4f7fa; }
.stats { margin-top: .5rem; font: 600 .88rem system-ui; color: #1d3557; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// BVH ray-traversal demo
// Scene: 8 triangles, 2-level BVH (root -> 2 child nodes -> 4+4 leaf nodes)
// A ray is shot from the left at a random Y angle.
// The traversal walks the tree: test node AABB, if miss -> prune, if hit -> recurse.
// Brute-force mode tests every triangle directly.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// โโ Triangles (scene coords) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Each tri: [{x,y},{x,y},{x,y}]
const TRIS = [
// left cluster (indices 0-3)
[{x:60,y:60},{x:100,y:40},{x:110,y:90}],
[{x:70,y:100},{x:120,y:90},{x:90,y:140}],
[{x:50,y:150},{x:95,y:130},{x:80,y:180}],
[{x:100,y:160},{x:140,y:140},{x:130,y:190}],
// right cluster (indices 4-7)
[{x:310,y:55},{x:360,y:40},{x:370,y:95}],
[{x:320,y:100},{x:375,y:90},{x:350,y:145}],
[{x:305,y:155},{x:360,y:140},{x:345,y:200}],
[{x:370,y:165},{x:420,y:145},{x:410,y:205}],
];
// Compute AABB for a set of tri indices
function aabb(indices) {
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
indices.forEach(i => TRIS[i].forEach(v => {
if(v.x<minX)minX=v.x; if(v.y<minY)minY=v.y;
if(v.x>maxX)maxX=v.x; if(v.y>maxY)maxY=v.y;
}));
const PAD=6;
return {minX:minX-PAD,minY:minY-PAD,maxX:maxX+PAD,maxY:maxY+PAD};
}
// BVH: 3 levels
const NODES = [
{ name:'Root', tris:[0,1,2,3,4,5,6,7], children:[1,2] },
{ name:'Left', tris:[0,1,2,3], children:[3,4] },
{ name:'Right', tris:[4,5,6,7], children:[5,6] },
{ name:'L0-1', tris:[0,1], children:[7,8] },
{ name:'L2-3', tris:[2,3], children:[9,10]},
{ name:'R4-5', tris:[4,5], children:[11,12]},
{ name:'R6-7', tris:[6,7], children:[13,14]},
{ name:'Tri0', tris:[0], children:[] },
{ name:'Tri1', tris:[1], children:[] },
{ name:'Tri2', tris:[2], children:[] },
{ name:'Tri3', tris:[3], children:[] },
{ name:'Tri4', tris:[4], children:[] },
{ name:'Tri5', tris:[5], children:[] },
{ name:'Tri6', tris:[6], children:[] },
{ name:'Tri7', tris:[7], children:[] },
];
NODES.forEach(n => { n.box = aabb(n.tris); });
// โโ Ray โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Ray from left edge at random Y, pointing right with slight vertical drift
let ray = null;
function newRay() {
const oy = 60 + Math.random() * 200;
const dx = 1, dy = (Math.random() - 0.5) * 0.35;
return { ox:0, oy, dx, dy };
}
// โโ Intersection helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Ray-AABB: slab method. Returns tMin or Infinity if miss.
function rayAABB(r, box) {
let tmin=-Infinity, tmax=Infinity;
// x slab
if(Math.abs(r.dx) < 1e-9) { if(r.ox<box.minX||r.ox>box.maxX) return Infinity; }
else {
const t1=(box.minX-r.ox)/r.dx, t2=(box.maxX-r.ox)/r.dx;
tmin=Math.max(tmin,Math.min(t1,t2)); tmax=Math.min(tmax,Math.max(t1,t2));
}
// y slab
if(Math.abs(r.dy) < 1e-9) { if(r.oy<box.minY||r.oy>box.maxY) return Infinity; }
else {
const t1=(box.minY-r.oy)/r.dy, t2=(box.maxY-r.oy)/r.dy;
tmin=Math.max(tmin,Math.min(t1,t2)); tmax=Math.min(tmax,Math.max(t1,t2));
}
return (tmax>=tmin && tmax>=0) ? Math.max(tmin,0) : Infinity;
}
// 2D ray-triangle intersection: test ray against each of the 3 edges,
// collect all valid edge crossings (s in [0,1], t>0), return the smallest t.
function raySegT(r, p1x, p1y, p2x, p2y) {
const ex=p2x-p1x, ey=p2y-p1y;
const denom = r.dx*ey - r.dy*ex;
if(Math.abs(denom)<1e-9) return Infinity;
const tx=p1x-r.ox, ty=p1y-r.oy;
const t=(tx*ey - ty*ex)/denom;
const s=(tx*r.dy - ty*r.dx)/denom;
return (t>1e-6 && s>=-1e-6 && s<=1+1e-6) ? t : Infinity;
}
function rayTri(r, tri) {
const [A,B,C] = tri;
const t0=raySegT(r,A.x,A.y,B.x,B.y);
const t1=raySegT(r,B.x,B.y,C.x,C.y);
const t2=raySegT(r,C.x,C.y,A.x,A.y);
const t=Math.min(t0,t1,t2);
return isFinite(t) ? t : Infinity;
}
// โโ Traversal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let log = []; // {nodeIdx, hit:bool, type:'box'|'tri', triIdx?}
function traverse(nodeIdx, r, tMax) {
const node = NODES[nodeIdx];
const tBox = rayAABB(r, node.box);
if(tBox >= tMax) {
log.push({nodeIdx, hit:false, type:'box'});
return {t:Infinity, triIdx:-1};
}
log.push({nodeIdx, hit:true, type:'box'});
// Leaf node
if(node.children.length === 0) {
const triIdx = node.tris[0];
const t = rayTri(r, TRIS[triIdx]);
log.push({nodeIdx, hit: t<Infinity, type:'tri', triIdx});
return {t, triIdx: t<Infinity ? triIdx : -1};
}
// Internal node: test both children
let best = {t:Infinity, triIdx:-1};
node.children.forEach(ci => {
const res = traverse(ci, r, best.t < Infinity ? best.t : tMax);
if(res.t < best.t) best = res;
});
return best;
}
function bruteForce(r) {
let best = {t:Infinity, triIdx:-1};
TRIS.forEach((tri, i) => {
log.push({nodeIdx:-1, hit:true, type:'tri', triIdx:i});
const t = rayTri(r, tri);
if(t < best.t) best = {t, triIdx:i};
});
return best;
}
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let result = null;
let animStep = 0, animTimer = null;
let isBrute = false;
function reset() {
if(animTimer) { clearInterval(animTimer); animTimer=null; }
ray=null; result=null; log=[]; animStep=0;
document.getElementById('stats').textContent = 'Press "Shoot ray" to start.';
draw();
}
function shoot() {
if(animTimer) { clearInterval(animTimer); animTimer=null; }
ray = newRay();
log = [];
isBrute = document.getElementById('brute').checked;
if(isBrute) result = bruteForce(ray);
else result = traverse(0, ray, Infinity);
animStep = 0;
animTimer = setInterval(() => {
animStep++;
if(animStep > log.length) { clearInterval(animTimer); animTimer=null; showStats(); }
draw();
}, 120);
}
function showStats() {
const boxTests = log.filter(e=>e.type==='box').length;
const triTests = log.filter(e=>e.type==='tri').length;
const mode = isBrute ? 'Brute force' : 'BVH';
const hit = result && result.triIdx >= 0 ? fmt('hit triangle %triIdx%', { triIdx: result.triIdx }) : 'no hit';
document.getElementById('stats').textContent =
fmt('%mode%: %boxTests% box test%v%, %triTests% triangle test%v2% โ %hit%', { mode: mode, boxTests: boxTests, v: boxTests!==1?'s':'', triTests: triTests, v2: triTests!==1?'s':'', hit: hit });
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const COLORS = {
boxHit: 'rgba(0,180,80,0.25)',
boxMiss: 'rgba(220,50,50,0.18)',
boxHitStroke: '#0a7d33',
boxMissStroke: '#c92f3c',
tri: '#2a4d7a',
triHit: '#e63946',
ray: '#f4a261',
};
function drawBox(box, fillColor, strokeColor) {
ctx.save();
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
ctx.beginPath();
ctx.rect(box.minX, box.minY, box.maxX-box.minX, box.maxY-box.minY);
ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawTri(tri, color, lw) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = lw||1.5;
ctx.fillStyle = 'rgba(42,77,122,0.06)';
ctx.beginPath();
ctx.moveTo(tri[0].x,tri[0].y);
ctx.lineTo(tri[1].x,tri[1].y);
ctx.lineTo(tri[2].x,tri[2].y);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawRay(r, tHit) {
const tEnd = tHit < Infinity ? tHit : W/Math.max(r.dx,0.001)*1.2;
ctx.save();
ctx.strokeStyle = COLORS.ray; ctx.lineWidth = 2;
ctx.setLineDash([6,3]);
ctx.beginPath();
ctx.moveTo(r.ox, r.oy);
ctx.lineTo(r.ox + r.dx*tEnd, r.oy + r.dy*tEnd);
ctx.stroke();
// arrowhead at start
ctx.setLineDash([]);
ctx.fillStyle = COLORS.ray;
ctx.beginPath();
ctx.arc(r.ox+4, r.oy, 4, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
function draw() {
ctx.clearRect(0,0,W,H);
// Draw animated log entries up to animStep
const visible = log.slice(0, animStep);
if(!isBrute) {
// Draw box states
visible.forEach(e => {
if(e.type==='box') {
const node = NODES[e.nodeIdx];
drawBox(node.box,
e.hit ? COLORS.boxHit : COLORS.boxMiss,
e.hit ? COLORS.boxHitStroke : COLORS.boxMissStroke);
}
});
}
// Draw triangles, highlight tested ones
const testedTris = new Set(visible.filter(e=>e.type==='tri').map(e=>e.triIdx));
TRIS.forEach((tri,i) => {
const color = (result && result.triIdx===i && animStep>log.length-1)
? COLORS.triHit : testedTris.has(i) ? '#1d3557' : COLORS.tri;
drawTri(tri, color, testedTris.has(i) ? 2 : 1.5);
});
// Draw ray
if(ray) {
const tHit = (result && animStep > log.length-1) ? result.t : Infinity;
drawRay(ray, tHit);
// hit point
if(result && result.triIdx>=0 && animStep > log.length-1) {
ctx.save();
ctx.fillStyle = '#e63946';
const hx = ray.ox + ray.dx*result.t, hy = ray.oy + ray.dy*result.t;
ctx.beginPath(); ctx.arc(hx,hy,5,0,Math.PI*2); ctx.fill();
ctx.restore();
}
}
// Legend
if(!ray) {
ctx.save();
ctx.fillStyle='#6b7a90'; ctx.font='13px system-ui';
ctx.fillText('8 triangles in 2 clusters โ a BVH is built over them', 12, H-12);
ctx.restore();
// Draw faint root box
drawBox(NODES[0].box,'rgba(100,140,180,0.07)','#c0c8d4');
TRIS.forEach((tri,i) => drawTri(tri, COLORS.tri, 1.5));
}
}
document.getElementById('shoot').addEventListener('click', shoot);
document.getElementById('reset').addEventListener('click', reset);
document.getElementById('brute').addEventListener('change', reset);
draw();
// โโ Node-side verification (runs silently) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
(function verify() {
// Test 1: Root AABB hit by ray at y=120
const testRay = {ox:0, oy:120, dx:1, dy:0};
const rootT = rayAABB(testRay, NODES[0].box);
if(!isFinite(rootT) || rootT < 0) throw new Error('Root AABB miss for y=120');
// Test 2: Left cluster AABB hit
const leftT = rayAABB(testRay, NODES[1].box);
if(!isFinite(leftT) || leftT < 0) throw new Error('Left AABB miss for y=120');
// Test 3: Ray at y=5 (above all triangles) misses all
const highRay = {ox:0, oy:5, dx:1, dy:0};
TRIS.forEach((tri,i) => {
const t = rayTri(highRay, tri);
if(t < Infinity) throw new Error('Tri '+i+' wrongly hit by y=5 ray, t='+t);
});
// Test 4: Ray through centroid of tri[1] hits it
const T1 = TRIS[1];
const cy = (T1[0].y+T1[1].y+T1[2].y)/3;
const centRay = {ox:0, oy:cy, dx:1, dy:0};
const t1 = rayTri(centRay, TRIS[1]);
if(!isFinite(t1) || t1 <= 0) throw new Error('Expected hit on tri[1] centroid, got '+t1);
// Test 5: BVH and brute-force agree on the same ray
const bfBest = TRIS.reduce((b,tri,i)=>{const t=rayTri(centRay,tri);return t<b.t?{t,i}:b},{t:Infinity,i:-1});
const bvhRes = (function tr(ni,r,tMax){
const nd=NODES[ni]; const tb=rayAABB(r,nd.box); if(tb>=tMax) return {t:Infinity,i:-1};
if(!nd.children.length){const i=nd.tris[0];const t=rayTri(r,TRIS[i]);return{t:t<Infinity?t:Infinity,i:t<Infinity?i:-1};}
let best={t:Infinity,i:-1};
nd.children.forEach(ci=>{const res=tr(ci,r,best.t<Infinity?best.t:tMax);if(res.t<best.t)best=res;});
return best;
})(0, centRay, Infinity);
if(bvhRes.i !== bfBest.i) throw new Error('BVH/BF disagree: BVH='+bvhRes.i+' BF='+bfBest.i);
console.log(fmt('BVH demo verification PASSED โ tri[1] hit at t=%t1%, BVH agrees', { t1: t1.toFixed(1) }));
})();
<div class="controls">
<button id="shoot" type="button">Shoot ray</button>
<label class="toggle"><input type="checkbox" id="brute"> Brute force</label>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="c" width="560" height="320"></canvas>
<div class="stats" id="stats">Press "Shoot ray" to start.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.toggle { display: flex; align-items: center; gap: .35rem; font: 600 13px system-ui;
user-select: none; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; display: block; max-width: 100%; background: #f4f7fa; }
.stats { margin-top: .5rem; font: 600 .88rem system-ui; color: #1d3557; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// BVH ray-traversal demo
// Scene: 8 triangles, 2-level BVH (root -> 2 child nodes -> 4+4 leaf nodes)
// A ray is shot from the left at a random Y angle.
// The traversal walks the tree: test node AABB, if miss -> prune, if hit -> recurse.
// Brute-force mode tests every triangle directly.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// โโ Triangles (scene coords) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Each tri: [{x,y},{x,y},{x,y}]
const TRIS = [
// left cluster (indices 0-3)
[{x:60,y:60},{x:100,y:40},{x:110,y:90}],
[{x:70,y:100},{x:120,y:90},{x:90,y:140}],
[{x:50,y:150},{x:95,y:130},{x:80,y:180}],
[{x:100,y:160},{x:140,y:140},{x:130,y:190}],
// right cluster (indices 4-7)
[{x:310,y:55},{x:360,y:40},{x:370,y:95}],
[{x:320,y:100},{x:375,y:90},{x:350,y:145}],
[{x:305,y:155},{x:360,y:140},{x:345,y:200}],
[{x:370,y:165},{x:420,y:145},{x:410,y:205}],
];
// Compute AABB for a set of tri indices
function aabb(indices) {
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
indices.forEach(i => TRIS[i].forEach(v => {
if(v.x<minX)minX=v.x; if(v.y<minY)minY=v.y;
if(v.x>maxX)maxX=v.x; if(v.y>maxY)maxY=v.y;
}));
const PAD=6;
return {minX:minX-PAD,minY:minY-PAD,maxX:maxX+PAD,maxY:maxY+PAD};
}
// BVH: 3 levels
const NODES = [
{ name:'Root', tris:[0,1,2,3,4,5,6,7], children:[1,2] },
{ name:'Left', tris:[0,1,2,3], children:[3,4] },
{ name:'Right', tris:[4,5,6,7], children:[5,6] },
{ name:'L0-1', tris:[0,1], children:[7,8] },
{ name:'L2-3', tris:[2,3], children:[9,10]},
{ name:'R4-5', tris:[4,5], children:[11,12]},
{ name:'R6-7', tris:[6,7], children:[13,14]},
{ name:'Tri0', tris:[0], children:[] },
{ name:'Tri1', tris:[1], children:[] },
{ name:'Tri2', tris:[2], children:[] },
{ name:'Tri3', tris:[3], children:[] },
{ name:'Tri4', tris:[4], children:[] },
{ name:'Tri5', tris:[5], children:[] },
{ name:'Tri6', tris:[6], children:[] },
{ name:'Tri7', tris:[7], children:[] },
];
NODES.forEach(n => { n.box = aabb(n.tris); });
// โโ Ray โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Ray from left edge at random Y, pointing right with slight vertical drift
let ray = null;
function newRay() {
const oy = 60 + Math.random() * 200;
const dx = 1, dy = (Math.random() - 0.5) * 0.35;
return { ox:0, oy, dx, dy };
}
// โโ Intersection helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Ray-AABB: slab method. Returns tMin or Infinity if miss.
function rayAABB(r, box) {
let tmin=-Infinity, tmax=Infinity;
// x slab
if(Math.abs(r.dx) < 1e-9) { if(r.ox<box.minX||r.ox>box.maxX) return Infinity; }
else {
const t1=(box.minX-r.ox)/r.dx, t2=(box.maxX-r.ox)/r.dx;
tmin=Math.max(tmin,Math.min(t1,t2)); tmax=Math.min(tmax,Math.max(t1,t2));
}
// y slab
if(Math.abs(r.dy) < 1e-9) { if(r.oy<box.minY||r.oy>box.maxY) return Infinity; }
else {
const t1=(box.minY-r.oy)/r.dy, t2=(box.maxY-r.oy)/r.dy;
tmin=Math.max(tmin,Math.min(t1,t2)); tmax=Math.min(tmax,Math.max(t1,t2));
}
return (tmax>=tmin && tmax>=0) ? Math.max(tmin,0) : Infinity;
}
// 2D ray-triangle intersection: test ray against each of the 3 edges,
// collect all valid edge crossings (s in [0,1], t>0), return the smallest t.
function raySegT(r, p1x, p1y, p2x, p2y) {
const ex=p2x-p1x, ey=p2y-p1y;
const denom = r.dx*ey - r.dy*ex;
if(Math.abs(denom)<1e-9) return Infinity;
const tx=p1x-r.ox, ty=p1y-r.oy;
const t=(tx*ey - ty*ex)/denom;
const s=(tx*r.dy - ty*r.dx)/denom;
return (t>1e-6 && s>=-1e-6 && s<=1+1e-6) ? t : Infinity;
}
function rayTri(r, tri) {
const [A,B,C] = tri;
const t0=raySegT(r,A.x,A.y,B.x,B.y);
const t1=raySegT(r,B.x,B.y,C.x,C.y);
const t2=raySegT(r,C.x,C.y,A.x,A.y);
const t=Math.min(t0,t1,t2);
return isFinite(t) ? t : Infinity;
}
// โโ Traversal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let log = []; // {nodeIdx, hit:bool, type:'box'|'tri', triIdx?}
function traverse(nodeIdx, r, tMax) {
const node = NODES[nodeIdx];
const tBox = rayAABB(r, node.box);
if(tBox >= tMax) {
log.push({nodeIdx, hit:false, type:'box'});
return {t:Infinity, triIdx:-1};
}
log.push({nodeIdx, hit:true, type:'box'});
// Leaf node
if(node.children.length === 0) {
const triIdx = node.tris[0];
const t = rayTri(r, TRIS[triIdx]);
log.push({nodeIdx, hit: t<Infinity, type:'tri', triIdx});
return {t, triIdx: t<Infinity ? triIdx : -1};
}
// Internal node: test both children
let best = {t:Infinity, triIdx:-1};
node.children.forEach(ci => {
const res = traverse(ci, r, best.t < Infinity ? best.t : tMax);
if(res.t < best.t) best = res;
});
return best;
}
function bruteForce(r) {
let best = {t:Infinity, triIdx:-1};
TRIS.forEach((tri, i) => {
log.push({nodeIdx:-1, hit:true, type:'tri', triIdx:i});
const t = rayTri(r, tri);
if(t < best.t) best = {t, triIdx:i};
});
return best;
}
// โโ State โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let result = null;
let animStep = 0, animTimer = null;
let isBrute = false;
function reset() {
if(animTimer) { clearInterval(animTimer); animTimer=null; }
ray=null; result=null; log=[]; animStep=0;
document.getElementById('stats').textContent = 'Press "Shoot ray" to start.';
draw();
}
function shoot() {
if(animTimer) { clearInterval(animTimer); animTimer=null; }
ray = newRay();
log = [];
isBrute = document.getElementById('brute').checked;
if(isBrute) result = bruteForce(ray);
else result = traverse(0, ray, Infinity);
animStep = 0;
animTimer = setInterval(() => {
animStep++;
if(animStep > log.length) { clearInterval(animTimer); animTimer=null; showStats(); }
draw();
}, 120);
}
function showStats() {
const boxTests = log.filter(e=>e.type==='box').length;
const triTests = log.filter(e=>e.type==='tri').length;
const mode = isBrute ? 'Brute force' : 'BVH';
const hit = result && result.triIdx >= 0 ? fmt('hit triangle %triIdx%', { triIdx: result.triIdx }) : 'no hit';
document.getElementById('stats').textContent =
fmt('%mode%: %boxTests% box test%v%, %triTests% triangle test%v2% โ %hit%', { mode: mode, boxTests: boxTests, v: boxTests!==1?'s':'', triTests: triTests, v2: triTests!==1?'s':'', hit: hit });
}
// โโ Drawing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const COLORS = {
boxHit: 'rgba(0,180,80,0.25)',
boxMiss: 'rgba(220,50,50,0.18)',
boxHitStroke: '#0a7d33',
boxMissStroke: '#c92f3c',
tri: '#2a4d7a',
triHit: '#e63946',
ray: '#f4a261',
};
function drawBox(box, fillColor, strokeColor) {
ctx.save();
ctx.fillStyle = fillColor;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
ctx.beginPath();
ctx.rect(box.minX, box.minY, box.maxX-box.minX, box.maxY-box.minY);
ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawTri(tri, color, lw) {
ctx.save();
ctx.strokeStyle = color; ctx.lineWidth = lw||1.5;
ctx.fillStyle = 'rgba(42,77,122,0.06)';
ctx.beginPath();
ctx.moveTo(tri[0].x,tri[0].y);
ctx.lineTo(tri[1].x,tri[1].y);
ctx.lineTo(tri[2].x,tri[2].y);
ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.restore();
}
function drawRay(r, tHit) {
const tEnd = tHit < Infinity ? tHit : W/Math.max(r.dx,0.001)*1.2;
ctx.save();
ctx.strokeStyle = COLORS.ray; ctx.lineWidth = 2;
ctx.setLineDash([6,3]);
ctx.beginPath();
ctx.moveTo(r.ox, r.oy);
ctx.lineTo(r.ox + r.dx*tEnd, r.oy + r.dy*tEnd);
ctx.stroke();
// arrowhead at start
ctx.setLineDash([]);
ctx.fillStyle = COLORS.ray;
ctx.beginPath();
ctx.arc(r.ox+4, r.oy, 4, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
function draw() {
ctx.clearRect(0,0,W,H);
// Draw animated log entries up to animStep
const visible = log.slice(0, animStep);
if(!isBrute) {
// Draw box states
visible.forEach(e => {
if(e.type==='box') {
const node = NODES[e.nodeIdx];
drawBox(node.box,
e.hit ? COLORS.boxHit : COLORS.boxMiss,
e.hit ? COLORS.boxHitStroke : COLORS.boxMissStroke);
}
});
}
// Draw triangles, highlight tested ones
const testedTris = new Set(visible.filter(e=>e.type==='tri').map(e=>e.triIdx));
TRIS.forEach((tri,i) => {
const color = (result && result.triIdx===i && animStep>log.length-1)
? COLORS.triHit : testedTris.has(i) ? '#1d3557' : COLORS.tri;
drawTri(tri, color, testedTris.has(i) ? 2 : 1.5);
});
// Draw ray
if(ray) {
const tHit = (result && animStep > log.length-1) ? result.t : Infinity;
drawRay(ray, tHit);
// hit point
if(result && result.triIdx>=0 && animStep > log.length-1) {
ctx.save();
ctx.fillStyle = '#e63946';
const hx = ray.ox + ray.dx*result.t, hy = ray.oy + ray.dy*result.t;
ctx.beginPath(); ctx.arc(hx,hy,5,0,Math.PI*2); ctx.fill();
ctx.restore();
}
}
// Legend
if(!ray) {
ctx.save();
ctx.fillStyle='#6b7a90'; ctx.font='13px system-ui';
ctx.fillText('8 triangles in 2 clusters โ a BVH is built over them', 12, H-12);
ctx.restore();
// Draw faint root box
drawBox(NODES[0].box,'rgba(100,140,180,0.07)','#c0c8d4');
TRIS.forEach((tri,i) => drawTri(tri, COLORS.tri, 1.5));
}
}
document.getElementById('shoot').addEventListener('click', shoot);
document.getElementById('reset').addEventListener('click', reset);
document.getElementById('brute').addEventListener('change', reset);
draw();
// โโ Node-side verification (runs silently) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
(function verify() {
// Test 1: Root AABB hit by ray at y=120
const testRay = {ox:0, oy:120, dx:1, dy:0};
const rootT = rayAABB(testRay, NODES[0].box);
if(!isFinite(rootT) || rootT < 0) throw new Error('Root AABB miss for y=120');
// Test 2: Left cluster AABB hit
const leftT = rayAABB(testRay, NODES[1].box);
if(!isFinite(leftT) || leftT < 0) throw new Error('Left AABB miss for y=120');
// Test 3: Ray at y=5 (above all triangles) misses all
const highRay = {ox:0, oy:5, dx:1, dy:0};
TRIS.forEach((tri,i) => {
const t = rayTri(highRay, tri);
if(t < Infinity) throw new Error('Tri '+i+' wrongly hit by y=5 ray, t='+t);
});
// Test 4: Ray through centroid of tri[1] hits it
const T1 = TRIS[1];
const cy = (T1[0].y+T1[1].y+T1[2].y)/3;
const centRay = {ox:0, oy:cy, dx:1, dy:0};
const t1 = rayTri(centRay, TRIS[1]);
if(!isFinite(t1) || t1 <= 0) throw new Error('Expected hit on tri[1] centroid, got '+t1);
// Test 5: BVH and brute-force agree on the same ray
const bfBest = TRIS.reduce((b,tri,i)=>{const t=rayTri(centRay,tri);return t<b.t?{t,i}:b},{t:Infinity,i:-1});
const bvhRes = (function tr(ni,r,tMax){
const nd=NODES[ni]; const tb=rayAABB(r,nd.box); if(tb>=tMax) return {t:Infinity,i:-1};
if(!nd.children.length){const i=nd.tris[0];const t=rayTri(r,TRIS[i]);return{t:t<Infinity?t:Infinity,i:t<Infinity?i:-1};}
let best={t:Infinity,i:-1};
nd.children.forEach(ci=>{const res=tr(ci,r,best.t<Infinity?best.t:tMax);if(res.t<best.t)best=res;});
return best;
})(0, centRay, Infinity);
if(bvhRes.i !== bfBest.i) throw new Error('BVH/BF disagree: BVH='+bvhRes.i+' BF='+bfBest.i);
console.log(fmt('BVH demo verification PASSED โ tri[1] hit at t=%t1%, BVH agrees', { t1: t1.toFixed(1) }));
})();
The BVH needs a handful of box tests and at most one or two triangle tests. Brute force tests every triangle. Scale this to a million triangles and the gap becomes the difference between real time and waiting hours.
Comments
Loading comments...