export const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); export const key = (p) => `${p[0]},${p[1]},${p[2]}`; export const direction = (yaw, pitch) => [ Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch), ]; export function multiply(a, b) { const o = new Float32Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; return o; } export function perspective(fovy, aspect, near, far) { const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far); return new Float32Array([ f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0, ]); } export function viewMatrix(eye, yaw, pitch) { const d = direction(yaw, pitch), z = d.map((v) => -v), x = [Math.cos(yaw), 0, Math.sin(yaw)], y = [ -Math.sin(yaw) * Math.sin(pitch), Math.cos(pitch), Math.cos(yaw) * Math.sin(pitch), ]; return new Float32Array([ x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1, ]); } export function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } export function project(p, m, w, h) { const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], q = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; return q > 0 && z / q < 1 ? [((x / q + 1) * w) / 2, ((1 - y / q) * h) / 2] : null; } export const unitBox = { min: [0, 0, 0], max: [1, 1, 1] }; export function normalizeBoxes(boxes) { return (boxes || []) .map((b) => Array.isArray(b) ? { min: b.slice(0, 3), max: b.slice(3, 6) } : b, ) .filter((b) => b.min?.length === 3 && b.max?.length === 3); } export function rayBox(origin, dir, min, max, reach) { let tmin = -Infinity, tmax = Infinity, normal = [0, 0, 0]; for (let i = 0; i < 3; i++) { if (Math.abs(dir[i]) < 1e-9) { if (origin[i] < min[i] || origin[i] > max[i]) return null; continue; } let a = (min[i] - origin[i]) / dir[i], b = (max[i] - origin[i]) / dir[i], sign = -Math.sign(dir[i]); if (a > b) [a, b] = [b, a]; if (a > tmin) { tmin = a; normal = [0, 0, 0]; normal[i] = sign; } tmax = Math.min(tmax, b); if (tmin > tmax) return null; } if (tmax < 0 || tmin > reach) return null; return { distance: Math.max(0, tmin), normal }; } // A bounded DDA finds candidate cells; exact material boxes determine selection and face. export function raycast(origin, dir, blocks, materials, reach = 6) { const cell = origin.map(Math.floor), step = dir.map(Math.sign); const delta = dir.map((v) => (v === 0 ? Infinity : Math.abs(1 / v))); const next = dir.map((v, i) => v === 0 ? Infinity : ((v > 0 ? cell[i] + 1 : cell[i]) - origin[i]) / v, ); const visited = new Set(); let best = null; for (let n = 0; n < 128; n++) { // Official shapes can extend outside their owning cell (e.g. a fence is 1.5 high). // Inspect neighboring owners once, but intersect their exact boxes in world space. for (let x = -3; x <= 3; x++) for (let y = -3; y <= 3; y++) for (let z = -3; z <= 3; z++) { const owner = [cell[0] + x, cell[1] + y, cell[2] + z], idKey = key(owner); if (visited.has(idKey)) continue; visited.add(idKey); const id = blocks.get(idKey); if (!id) continue; const mat = materials.get(id), boxes = mat ? mat.render : [unitBox]; for (const box of boxes || [unitBox]) { const hit = rayBox( origin, dir, box.min.map((v, i) => v + owner[i]), box.max.map((v, i) => v + owner[i]), reach, ); if (hit && (!best || hit.distance < best.distance)) best = { ...hit, pos: owner, block: id }; } } const axis = next[0] < next[1] ? next[0] < next[2] ? 0 : 2 : next[1] < next[2] ? 1 : 2; if ( next[axis] > reach || !Number.isFinite(next[axis]) || (best && next[axis] > best.distance) ) break; cell[axis] += step[axis]; next[axis] += delta[axis]; } return best; } export function isFullCube(mat) { const b = mat?.render; return ( b?.length === 1 && b[0].min.every((v) => v === 0) && b[0].max.every((v) => v === 1) && (mat.opacity ?? 1) >= 1 && !mat.transparent ); }