diff --git a/kidlisp/examples/meshes/corridor.lisp b/kidlisp/examples/meshes/corridor.lisp index 5a6c86e8d5..51f0a34b76 100644 --- a/kidlisp/examples/meshes/corridor.lisp +++ b/kidlisp/examples/meshes/corridor.lisp @@ -4,7 +4,7 @@ ; geometry is described once, the camera and placements per frame, and the ; host projects. The piece never touches a vertex, so a frame is a few ; hundred words of work wherever it runs. -(def NEAR_PLANE 1) +(def NEAR_PLANE 6) ; a face closer than this is cut, so the eye never sits on one ; a brick is the face you see: the Xbox draws at most 8,192 triangles a frame ; and does not cull, so a wall of cubes would be dropped past that. (mesh brick (face 0 -13 30 0 -13 -30 0 13 -30 0 13 30 170 86 60)) @@ -27,9 +27,21 @@ (now camx (* (sin (* tt 0.5)) 80)) (now camz (+ (% (* tt 220) 3400) 200)) (now yaw (* (sin (* tt 0.7)) 0.25))) -(now camx (clamp camx -560 560)) (now camz (clamp camz 100 3900)) +(now camx (clamp camx -470 470)) (now camz (clamp camz 100 3900)) +; the eye stays out of the pillars and crates: pushed off each one it is inside, with a margin +(def px 0) (def pz 0) (def dx 0) (def dz 0) +(repeat 8 pi + (now pz (+ (* pi 520) 300)) + (now dz (- camz pz)) + (if (< (abs dz) 75) (if (< camx -420) (now camx -420)) (if (> camx 420) (now camx 420)))) +(repeat 6 ci + (now pz (+ (* ci 650) 500)) (now px (- (* (% ci 3) 200) 200)) + (now dx (- camx px)) (now dz (- camz pz)) + (if (if (< (abs dx) 70) (if (< (abs dz) 70) 1 else 0) else 0) + (if (> (abs dx) (abs dz)) (now camx (+ px (* (sign dx) 70))) else (now camz (+ pz (* (sign dz) 70)))))) (wipe 90 140 210) +(light -0.5 -1 0.35) ; the sun: down, a little left, a little forward (camera camx 60 camz yaw pitch 70 NEAR_PLANE) (place floor 0 -150 2000) (place ceiling 0 150 2000) diff --git a/kidlisp/tools/native-tv-script.mjs b/kidlisp/tools/native-tv-script.mjs index 84c4474848..651993fd0d 100644 --- a/kidlisp/tools/native-tv-script.mjs +++ b/kidlisp/tools/native-tv-script.mjs @@ -104,13 +104,18 @@ const visit = { // host's retained meshes where it has them (the Xbox: meshUpload/meshDraw // with the 27-float camera) and through the projector otherwise, far to near, // as triangle3d with depth when the host has it, else as plain triangles. -const { sceneCamera, placeMesh, projectMesh, FaceList } = KidLispNative; +const { sceneCamera, placeMesh, projectMesh, FaceList, bakeLighting } = KidLispNative; +let sun = null; const faceList = new FaceList(); const emitFace = (x1, y1, x2, y2, x3, y3, depth, r, g, b) => { if (host3d) triangle3d(C(x1), C(y1), depth, C(x2), C(y2), depth, C(x3), C(y3), depth, r, g, b); else triangle(C(x1), C(y1), C(x2), C(y2), C(x3), C(y3), r, g, b); }; let camera27 = null; const handles = new Map(), missesById = new Map(); let meshFaces = 0, uploadsRefused = 0; const hostMeshes = typeof meshUpload === "function" && typeof meshDraw === "function"; const host3d = typeof triangle3d === "function"; -visit.camera = (x, y, z, yaw, pitch, fov, near) => { camera27 = sceneCamera(x * S, y * S, z * S, yaw, pitch, fov, HW, HH, camera27 || new Float32Array(27), (near || 1) * S); }; +visit.camera = (x, y, z, yaw, pitch, fov, near) => { camera27 = sceneCamera(x * S, y * S, z * S, yaw, pitch, fov, HW, HH, camera27 || new Float32Array(27), (near || 1) * S); if (sun) { camera27[24] = sun[0]; camera27[25] = sun[1]; camera27[26] = sun[2]; } }; +visit.light = (x, y, z) => { const l = Math.hypot(x, y, z) || 1; sun = [x / l, y / l, z / l]; if (camera27) { camera27[24] = sun[0]; camera27[25] = sun[1]; camera27[26] = sun[2]; } }; +// A host that lights its own meshes gets pre-lit colours and a camera with its sun off, so every host shows the one light model. +let hostCamera = null; +const cameraForHost = () => { hostCamera = hostCamera || new Float32Array(27); hostCamera.set(camera27); hostCamera[24] = 0; hostCamera[25] = 0; hostCamera[26] = 0; return hostCamera; }; visit.place = (id, x, y, z, yaw, pitch, roll, scale, a) => { const mesh = lisp && lisp.gpuFrame && lisp.gpuFrame.meshes.get(id); if (!mesh || !camera27) return; if (hostMeshes) { @@ -120,7 +125,7 @@ visit.place = (id, x, y, z, yaw, pitch, roll, scale, a) => { // keyed by a numeric hash of the pose (no string per placement), verified against the stored pose let hsh = Math.imul(id, 2654435761); hsh = Math.imul(hsh ^ ((x * 1000) | 0), 2246822519); hsh = Math.imul(hsh ^ ((y * 1000) | 0), 3266489917); hsh = Math.imul(hsh ^ ((z * 1000) | 0), 668265263); - hsh = Math.imul(hsh ^ ((yaw * 100000) | 0), 2246822519); hsh = Math.imul(hsh ^ ((pitch * 100000) | 0), 3266489917); hsh = Math.imul(hsh ^ ((roll * 100000) | 0), 668265263); hsh = (hsh ^ ((scale * 1000) | 0)) >>> 0; + hsh = Math.imul(hsh ^ ((yaw * 100000) | 0), 2246822519); hsh = Math.imul(hsh ^ ((pitch * 100000) | 0), 3266489917); hsh = Math.imul(hsh ^ ((roll * 100000) | 0), 668265263); hsh = Math.imul(hsh ^ ((scale * 1000) | 0), 2246822519); if (sun) hsh = Math.imul(hsh ^ ((sun[0] * 1000) | 0) ^ (((sun[1] * 1000) | 0) << 10) ^ (((sun[2] * 1000) | 0) << 20), 3266489917); hsh = hsh >>> 0; let entry = handles.get(hsh); if (entry !== undefined && (entry.id !== id || entry.x !== x || entry.y !== y || entry.z !== z || entry.yaw !== yaw || entry.pitch !== pitch || entry.roll !== roll || entry.scale !== scale)) { if (typeof meshFree === "function") meshFree(entry.handle); handles.delete(hsh); entry = undefined; } if (entry === undefined) { @@ -131,18 +136,18 @@ visit.place = (id, x, y, z, yaw, pitch, roll, scale, a) => { meshFaces += projectMesh(camera27, placeMesh(mesh, x * S, y * S, z * S, yaw, pitch, roll, scale * S), faceList, a); return; } - const world = placeMesh(mesh, x * S, y * S, z * S, yaw, pitch, roll, scale * S); + const world = bakeLighting(placeMesh(mesh, x * S, y * S, z * S, yaw, pitch, roll, scale * S), camera27); entry = { id, x, y, z, yaw, pitch, roll, scale, handle: meshUpload(world.verts, world.faces) }; handles.set(hsh, entry); if (entry.handle < 0) uploadsRefused++; if (handles.size > 3000) { for (const [k, e] of handles) { if (typeof meshFree === "function") meshFree(e.handle); handles.delete(k); if (handles.size <= 2000) break; } } } - if (entry.handle >= 0) meshFaces += meshDraw(entry.handle, camera27, 1, 1, 1, 1, a / 255) | 0; + if (entry.handle >= 0) meshFaces += meshDraw(entry.handle, cameraForHost(), 1, 1, 1, 1, a / 255) | 0; return; } meshFaces += projectMesh(camera27, placeMesh(mesh, x * S, y * S, z * S, yaw, pitch, roll, scale * S), faceList, a); }; -const COUNTS = { clear: 0, line: 0, box: 0, oval: 0, tri: 0, shape: 0, camera: 0, place: 0 }; -const counting = { clear: () => COUNTS.clear++, line: () => COUNTS.line++, box: () => COUNTS.box++, oval: () => COUNTS.oval++, tri: () => COUNTS.tri++, shape: () => COUNTS.shape++, camera: () => COUNTS.camera++, place: () => COUNTS.place++ }; +const COUNTS = { clear: 0, line: 0, box: 0, oval: 0, tri: 0, shape: 0, camera: 0, place: 0, light: 0 }; +const counting = { clear: () => COUNTS.clear++, line: () => COUNTS.line++, box: () => COUNTS.box++, oval: () => COUNTS.oval++, tri: () => COUNTS.tri++, shape: () => COUNTS.shape++, camera: () => COUNTS.camera++, place: () => COUNTS.place++, light: () => COUNTS.light++ }; function drawFrame(buffer) { lastOps = buffer.length; for (const k in COUNTS) COUNTS[k] = 0; meshFaces = 0; readFrame(buffer, counting); lastCounts = JSON.stringify(COUNTS); S = HW / W; const d0 = Date.now(); faceList.reset(); if (DRAW) { readFrame(buffer, visit); faceList.flush(emitFace); } lastDrawMs = Date.now() - d0; @@ -186,7 +191,7 @@ function paint() { fpsCount++; if (Date.now() - fpsAt >= 1000) { fps = fpsCount; fpsCount = 0; fpsAt = Date.now(); } const ms = Date.now() - t0; if (DENSITY_AUTO) { if (ms > BUDGET_MS) density = Math.max(0.25, density * Math.max(0.7, Math.sqrt(BUDGET_MS / ms))); else if (ms < BUDGET_MS * 0.7) density = Math.min(1, density * 1.04); } - if (frames % 10 === 1) say("KIDLISP", JSON.stringify({ piece: ORDER[current], frame: frames, ms, density: Math.round(density * 100) / 100, drawMs: lastDrawMs, ops: lastOps, counts: lastCounts, meshFaces, uploadsRefused, handles: handles.size, w: W, h: H, err: lastErr })); + if (frames % 10 === 1) say("KIDLISP", JSON.stringify({ piece: ORDER[current], frame: frames, ms, density: Math.round(density * 100) / 100, drawMs: lastDrawMs, ops: lastOps, counts: lastCounts, meshFaces, sun: sun ? sun.map((v) => +v.toFixed(2)) : null, uploadsRefused, handles: handles.size, w: W, h: H, err: lastErr })); box(6, 6, Math.min(HW - 12, fps * 3), 4, 255, 255, 255); box(6, 12, Math.min(HW - 12, ms), 4, 255, 120, 60); // fps and ms, as bars, in host pixels } function act() {} diff --git a/kidlisp/tools/native/entry.mjs b/kidlisp/tools/native/entry.mjs index 8aaa9811c6..8d16cd0dfc 100644 --- a/kidlisp/tools/native/entry.mjs +++ b/kidlisp/tools/native/entry.mjs @@ -6,4 +6,4 @@ export { KidLisp } from '../../../system/public/aesthetic.computer/lib/kidlisp.m export { compileProgram } from '../../../system/public/aesthetic.computer/lib/kidlisp-compile.mjs'; export { programHelpers } from '../../../system/public/aesthetic.computer/lib/kidlisp-emit.mjs'; export { GpuFrame, readFrame } from '../../../system/public/aesthetic.computer/lib/gpu-frame.mjs'; -export { sceneCamera, placeMesh, projectMesh, buildMesh, FaceList } from '../../../system/public/aesthetic.computer/lib/kidlisp-mesh.mjs'; +export { sceneCamera, placeMesh, projectMesh, buildMesh, FaceList, bakeLighting } from '../../../system/public/aesthetic.computer/lib/kidlisp-mesh.mjs'; diff --git a/system/public/aesthetic.computer/lib/disk-worker-manifest.json b/system/public/aesthetic.computer/lib/disk-worker-manifest.json index c5e83b4bfa..b2284b5ca0 100644 --- a/system/public/aesthetic.computer/lib/disk-worker-manifest.json +++ b/system/public/aesthetic.computer/lib/disk-worker-manifest.json @@ -1,8 +1,8 @@ { - "filename": "disk.worker.5bf59277e25b.mjs", - "sha256": "5bf59277e25b6b642baaa7cfc4d7ea3e2cbac596ac05a24b0a8e17bbc31a53f6", - "bytes": 1800534, - "sourceSha256": "8caabacbb02473531ddc9148e1d5e92a51f7c0c05a0031e4923ec0cfd56857e8", + "filename": "disk.worker.28c2634e0fe1.mjs", + "sha256": "28c2634e0fe151bbf0ddbcc0d25382a27d8c26d4a489c6e50491f080ccc71351", + "bytes": 1801991, + "sourceSha256": "1264b0697b9e8efb75b4972e6bc421108861fd1ddc1e4c21fa92cc1bb30969af", "sources": [ "public/aesthetic.computer/dep/@akamfoad/qr/qr.mjs", "public/aesthetic.computer/dep/gl-matrix/common.mjs", diff --git a/system/public/aesthetic.computer/lib/disk.worker.5bf59277e25b.mjs b/system/public/aesthetic.computer/lib/disk.worker.28c2634e0fe1.mjs similarity index 99% rename from system/public/aesthetic.computer/lib/disk.worker.5bf59277e25b.mjs rename to system/public/aesthetic.computer/lib/disk.worker.28c2634e0fe1.mjs index 3b330c2f53..0084406835 100644 --- a/system/public/aesthetic.computer/lib/disk.worker.5bf59277e25b.mjs +++ b/system/public/aesthetic.computer/lib/disk.worker.28c2634e0fe1.mjs @@ -16181,7 +16181,7 @@ var COMPARE = { var SCREEN = { width: (api) => api.screen?.width ?? 0, w: (api) => api.screen?.width ?? 0, height: (api) => api.screen?.height ?? 0, h: (api) => api.screen?.height ?? 0, frame: (api) => api.paintCount || 0, f: (api) => api.paintCount || 0 }; var DELEGATE_TOP = /* @__PURE__ */ new Set(["mesh", "tap", "draw", "lift", "once", "melody", "clock", "later", "jump", "hop", "delay", "trans", "net", "source", "choose", "?", "bake", "embed", "fps", "resolution", "die", "mic", "speaker", "overtone", "amplitude"]); var RAW_ANYWHERE = /* @__PURE__ */ new Set(["hum", "tune", "hush", "pluck", "bell", "sub", "flute", "hat", "voice"]); -var GPU_HEADS = /* @__PURE__ */ new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place"]); +var GPU_HEADS = /* @__PURE__ */ new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place", "light"]); var isTimerHead = (head) => typeof head === "number" || typeof head === "string" && /^\d*\.?\d+s(?:!|\.{2,3})?$/.test(head); var unquote = (s2) => typeof s2 === "string" && /^".*"$/s.test(s2) ? s2.slice(1, -1) : s2; function compileProgram(ast, lisp) { @@ -16558,6 +16558,12 @@ function compileProgram(ast, lisp) { return; } break; + case "light": + if (n3 >= 3 && v2.slice(0, 3).every((x) => typeof x === "number")) { + frame2.light(v2[0], v2[1], v2[2]); + return; + } + break; case "place": { const mesh = lisp.meshes?.get(String(v2[0])); if (mesh && n3 >= 4 && v2.slice(1, 4).every((x) => typeof x === "number")) { @@ -16794,11 +16800,18 @@ function compileProgram(ast, lisp) { var DEPTH_BASE = -1.4; var DEPTH_SLOPE = 2.8 / 16e3; var NEAR = 1; -var LIGHT = normalize5([-0.42, 1, -0.28]); +var LIGHT = normalize5([-0.42, -1, -0.28]); +var AMBIENT = 0.34; function normalize5(v2) { const l2 = Math.hypot(v2[0], v2[1], v2[2]) || 1; return [v2[0] / l2, v2[1] / l2, v2[2] / l2]; } +function lightFor(nx, ny, nz, m) { + const lx = m[24], ly = m[25], lz = m[26]; + const diffuse = Math.max(0, -(nx * lx + ny * ly + nz * lz)); + const bounce = Math.max(0, -ny) * 0.12; + return Math.min(1.25, AMBIENT + (1 - AMBIENT) * diffuse + bounce); +} function buildMesh(forms) { const verts = [], faces = []; const vertex = (x, y, z) => { @@ -16941,8 +16954,8 @@ function projectMesh(m, world2, emitOrList, alpha = 255) { if (poly2.length < 3) continue; poly2 = clip(poly2, (p) => p[2] - near); if (poly2.length < 3) continue; - const light = 0.72 + Math.max(0, -(nx * m[24] + ny * m[25] + nz * m[26])) * 0.28; - const r2 = Math.round(faces[f2 + 4] * light), g = Math.round(faces[f2 + 5] * light), b2 = Math.round(faces[f2 + 6] * light); + const light = lightFor(nx, ny, nz, m); + const r2 = Math.min(255, Math.round(faces[f2 + 4] * light)), g = Math.min(255, Math.round(faces[f2 + 5] * light)), b2 = Math.min(255, Math.round(faces[f2 + 6] * light)); let depthSum = 0; let screen2 = poly2.map((p) => { const k = m[14] + (m[15] / p[2] - m[14]) * m[16]; @@ -16977,7 +16990,7 @@ function clip(poly2, dist5) { } // public/aesthetic.computer/lib/gpu-frame.mjs -var OP = Object.freeze({ CLEAR: 1, LINE: 2, BOX: 3, OVAL: 4, TRI: 5, SHAPE: 6, CAMERA: 7, PLACE: 8 }); +var OP = Object.freeze({ CLEAR: 1, LINE: 2, BOX: 3, OVAL: 4, TRI: 5, SHAPE: 6, CAMERA: 7, PLACE: 8, LIGHT: 9 }); var GpuFrame = class { constructor(capacity = 1 << 16) { this.buffer = new Float32Array(capacity); @@ -17010,6 +17023,17 @@ var GpuFrame = class { this.length = i2; this.ops++; } + light(x, y, z) { + this.grow(4); + const o2 = this.buffer; + let i2 = this.length; + o2[i2++] = OP.LIGHT; + o2[i2++] = x; + o2[i2++] = y; + o2[i2++] = z; + this.length = i2; + this.ops++; + } place(mesh, x, y, z, yaw = 0, pitch = 0, roll = 0, scale7 = 1) { this.grow(10); const o2 = this.buffer; @@ -17219,6 +17243,7 @@ var KIDLISP_FUNCTIONS = /* @__PURE__ */ new Set([ "mesh", "camera", "place", + "light", // Output "print", "debug", @@ -21412,6 +21437,26 @@ ${")".repeat(missingCount)}`; camera: (api, args = [], env) => { const v2 = args.map((a2) => numArg(api, env, a2, 0)); this.camera27 = sceneCamera(v2[0] ?? 0, v2[1] ?? 0, v2[2] ?? 0, v2[3] ?? 0, v2[4] ?? 0, args.length > 5 ? v2[5] : 60, api.screen?.width ?? 0, api.screen?.height ?? 0, this.camera27 || new Float32Array(27), args.length > 6 ? v2[6] : 1); + if (this.lightDir) { + this.camera27[24] = this.lightDir[0]; + this.camera27[25] = this.lightDir[1]; + this.camera27[26] = this.lightDir[2]; + } + return 0; + }, + // (light x y z): the direction the sun shines towards. The camera carries it. + light: (api, args = [], env) => { + const v2 = normalize5(args.slice(0, 3).map((a2) => numArg(api, env, a2, 0))); + this.lightDir = v2; + if (this.gpuActive && this.gpuFrame) { + this.gpuFrame.light(v2[0], v2[1], v2[2]); + return 0; + } + if (this.camera27) { + this.camera27[24] = v2[0]; + this.camera27[25] = v2[1]; + this.camera27[26] = v2[2]; + } return 0; }, // (place name x y z [yaw pitch roll scale]): a mesh at a pose. On the GPU diff --git a/system/public/aesthetic.computer/lib/gpu-frame-renderer.mjs b/system/public/aesthetic.computer/lib/gpu-frame-renderer.mjs index 9b1f56fe2b..bf40b2bb28 100644 --- a/system/public/aesthetic.computer/lib/gpu-frame-renderer.mjs +++ b/system/public/aesthetic.computer/lib/gpu-frame-renderer.mjs @@ -8,7 +8,7 @@ import { readFrame } from "./gpu-frame.mjs"; import { sceneCamera, placeMesh, projectMesh, FaceList } from "./kidlisp-mesh.mjs"; const SEGMENTS = 24; // an oval's polygon -const FLOATS_PER_VERTEX = 6; // x y r g b a +const FLOATS_PER_VERTEX = 7; // x y z r g b a — z is depth: 0 for 2D (always in front), (0,1] for placed faces export async function createFrameRenderer(canvas) { if (typeof navigator === "undefined" || !navigator.gpu) return null; @@ -29,16 +29,19 @@ export async function createFrameRenderer(canvas) { struct U { resolution: vec2f, _pad: vec2f } @group(0) @binding(0) var u: U; struct VSOut { @builtin(position) pos: vec4f, @location(0) color: vec4f } - @vertex fn vs(@location(0) p: vec2f, @location(1) c: vec4f) -> VSOut { + @vertex fn vs(@location(0) p: vec3f, @location(1) c: vec4f) -> VSOut { var o: VSOut; - o.pos = vec4f(p.x / u.resolution.x * 2.0 - 1.0, 1.0 - p.y / u.resolution.y * 2.0, 0.0, 1.0); + o.pos = vec4f(p.x / u.resolution.x * 2.0 - 1.0, 1.0 - p.y / u.resolution.y * 2.0, p.z, 1.0); o.color = vec4f(c.rgb * c.a, c.a); // premultiplied for the blend below return o; } @fragment fn fs(@location(0) c: vec4f) -> @location(0) vec4f { return c; } ` }); const blend = { color: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" }, alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" } }; - const pipeline = device.createRenderPipeline({ layout: "auto", vertex: { module: shader, entryPoint: "vs", buffers: [{ arrayStride: FLOATS_PER_VERTEX * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }, { shaderLocation: 1, offset: 8, format: "float32x4" }] }] }, fragment: { module: shader, entryPoint: "fs", targets: [{ format, blend }] }, primitive: { topology: "triangle-list" } }); + const depthStencil = { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less-equal" }; + const pipeline = device.createRenderPipeline({ layout: "auto", vertex: { module: shader, entryPoint: "vs", buffers: [{ arrayStride: FLOATS_PER_VERTEX * 4, attributes: [{ shaderLocation: 0, offset: 0, format: "float32x3" }, { shaderLocation: 1, offset: 12, format: "float32x4" }] }] }, fragment: { module: shader, entryPoint: "fs", targets: [{ format, blend }] }, primitive: { topology: "triangle-list" }, depthStencil }); + let depthTexture = null, depthSize = { w: 0, h: 0 }; + const depthView = () => { if (!depthTexture || depthSize.w !== canvas.width || depthSize.h !== canvas.height) { depthTexture?.destroy(); depthTexture = device.createTexture({ size: [canvas.width, canvas.height], format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT }); depthSize = { w: canvas.width, h: canvas.height }; } return depthTexture.createView(); }; const uniform = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniform } }] }); // The overlay: the CPU buffer as a texture over the scene. @@ -52,14 +55,14 @@ export async function createFrameRenderer(canvas) { } @fragment fn fs(@location(0) uv: vec2f) -> @location(0) vec4f { let c = textureSample(t, s, uv); return vec4f(c.rgb * c.a, c.a); } ` }); - const overlayPipeline = device.createRenderPipeline({ layout: "auto", vertex: { module: overlayShader, entryPoint: "vs" }, fragment: { module: overlayShader, entryPoint: "fs", targets: [{ format, blend }] }, primitive: { topology: "triangle-list" } }); + const overlayPipeline = device.createRenderPipeline({ layout: "auto", vertex: { module: overlayShader, entryPoint: "vs" }, fragment: { module: overlayShader, entryPoint: "fs", targets: [{ format, blend }] }, primitive: { topology: "triangle-list" }, depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "always" } }); const sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest" }); let overlayTexture = null, overlayBind = null, overlaySize = { w: 0, h: 0 }; let vertices = new Float32Array(1 << 18), count = 0, vertexBuffer = null, vertexCapacity = 0; const need = (n) => { if ((count + n) * FLOATS_PER_VERTEX > vertices.length) { const next = new Float32Array(Math.max(vertices.length * 2, (count + n) * FLOATS_PER_VERTEX)); next.set(vertices.subarray(0, count * FLOATS_PER_VERTEX)); vertices = next; } }; - const vert = (x, y, r, g, b, a) => { let i = count * FLOATS_PER_VERTEX; vertices[i] = x; vertices[i + 1] = y; vertices[i + 2] = r / 255; vertices[i + 3] = g / 255; vertices[i + 4] = b / 255; vertices[i + 5] = a / 255; count++; }; - const triangle = (x1, y1, x2, y2, x3, y3, r, g, b, a) => { need(3); vert(x1, y1, r, g, b, a); vert(x2, y2, r, g, b, a); vert(x3, y3, r, g, b, a); }; + const vert = (x, y, r, g, b, a, z = 0) => { let i = count * FLOATS_PER_VERTEX; vertices[i] = x; vertices[i + 1] = y; vertices[i + 2] = z; vertices[i + 3] = r / 255; vertices[i + 4] = g / 255; vertices[i + 5] = b / 255; vertices[i + 6] = a / 255; count++; }; + const triangle = (x1, y1, x2, y2, x3, y3, r, g, b, a, z = 0) => { need(3); vert(x1, y1, r, g, b, a, z); vert(x2, y2, r, g, b, a, z); vert(x3, y3, r, g, b, a, z); }; const quad = (x1, y1, x2, y2, x3, y3, x4, y4, r, g, b, a) => { triangle(x1, y1, x2, y2, x3, y3, r, g, b, a); triangle(x1, y1, x3, y3, x4, y4, r, g, b, a); }; const strokeLine = (x1, y1, x2, y2, th, r, g, b, a) => { // The software rasterizer paints pixel centres; a 1-px line here is a 1-px quad along the segment. @@ -97,9 +100,10 @@ export async function createFrameRenderer(canvas) { // The 3D layer: the projector (kidlisp-mesh.mjs) turns placed meshes into // triangles, far to near, through the same vertex buffer. The camera is in // canvas pixels; meshes come from the frame's table. - let camera27 = null, meshes = null, placed = 0; + let camera27 = null, meshes = null, placed = 0, sun = null; const faceList = new FaceList(); // every placed face of the frame, sorted once at the end - visit.camera = (x, y, z, yaw, pitch, fov, near) => { camera27 = sceneCamera(x, y, z, yaw, pitch, fov, canvas.width, canvas.height, camera27 || new Float32Array(27), near || 1); }; + visit.camera = (x, y, z, yaw, pitch, fov, near) => { camera27 = sceneCamera(x, y, z, yaw, pitch, fov, canvas.width, canvas.height, camera27 || new Float32Array(27), near || 1); if (sun) { camera27[24] = sun[0]; camera27[25] = sun[1]; camera27[26] = sun[2]; } }; + visit.light = (x, y, z) => { const l = Math.hypot(x, y, z) || 1; sun = [x / l, y / l, z / l]; if (camera27) { camera27[24] = sun[0]; camera27[25] = sun[1]; camera27[26] = sun[2]; } }; visit.place = (id, x, y, z, yaw, pitch, roll, scale, a) => { const mesh = meshes?.get(id); if (!mesh || !camera27) return; placed += projectMesh(camera27, placeMesh(mesh, x, y, z, yaw, pitch, roll, scale), faceList, a); @@ -116,7 +120,8 @@ export async function createFrameRenderer(canvas) { if (meshTable) meshes = meshTable; faceList.reset(); readFrame(frame, visit); - faceList.flush((x1, y1, x2, y2, x3, y3, depth, r, g, b, alpha) => triangle(x1, y1, x2, y2, x3, y3, r, g, b, alpha)); + // the face's depth (the Xbox's -1.4..1.4 range) into the depth buffer's (0, 1]; 2D stays at 0, in front + faceList.flush((x1, y1, x2, y2, x3, y3, depth, r, g, b, alpha) => triangle(x1, y1, x2, y2, x3, y3, r, g, b, alpha, Math.min(1, Math.max(0.001, (depth + 1.5) / 3)))); const bytes = count * FLOATS_PER_VERTEX * 4; if (bytes > vertexCapacity) { vertexBuffer?.destroy(); vertexCapacity = Math.max(bytes, 1 << 16); vertexBuffer = device.createBuffer({ size: vertexCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST }); } if (bytes) device.queue.writeBuffer(vertexBuffer, 0, vertices.buffer, 0, bytes); @@ -133,13 +138,13 @@ export async function createFrameRenderer(canvas) { drawOverlay = true; } const encoder = device.createCommandEncoder(); - const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: "clear", storeOp: "store", clearValue: clearColor }] }); + const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(), loadOp: "clear", storeOp: "store", clearValue: clearColor }], depthStencilAttachment: { view: depthView(), depthClearValue: 1, depthLoadOp: "clear", depthStoreOp: "store" } }); if (count) { pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.setVertexBuffer(0, vertexBuffer, 0, bytes); pass.draw(count); } if (drawOverlay) { pass.setPipeline(overlayPipeline); pass.setBindGroup(0, overlayBind); pass.draw(6); } pass.end(); device.queue.submit([encoder.finish()]); return count; }, - destroy() { vertexBuffer?.destroy(); overlayTexture?.destroy(); device.destroy?.(); }, + destroy() { vertexBuffer?.destroy(); overlayTexture?.destroy(); depthTexture?.destroy(); device.destroy?.(); }, }; } diff --git a/system/public/aesthetic.computer/lib/gpu-frame.mjs b/system/public/aesthetic.computer/lib/gpu-frame.mjs index 52af995d05..b522a7d9eb 100644 --- a/system/public/aesthetic.computer/lib/gpu-frame.mjs +++ b/system/public/aesthetic.computer/lib/gpu-frame.mjs @@ -16,7 +16,8 @@ // SHAPE n fill r g b a x1 y1 … xn yn // CAMERA x y z yaw pitch fov near (the 3D layer, kidlisp-mesh.mjs) // PLACE mesh x y z yaw pitch roll scale a mesh = an id in frame.meshes -export const OP = Object.freeze({ CLEAR: 1, LINE: 2, BOX: 3, OVAL: 4, TRI: 5, SHAPE: 6, CAMERA: 7, PLACE: 8 }); +// LIGHT x y z the sun's direction (towards which it shines) +export const OP = Object.freeze({ CLEAR: 1, LINE: 2, BOX: 3, OVAL: 4, TRI: 5, SHAPE: 6, CAMERA: 7, PLACE: 8, LIGHT: 9 }); export class GpuFrame { constructor(capacity = 1 << 16) { @@ -30,6 +31,7 @@ export class GpuFrame { } defineMesh(id, mesh) { this.meshes.set(id, mesh); this.meshVersion++; } camera(x, y, z, yaw, pitch, fov, near = 1) { this.grow(8); const o = this.buffer; let i = this.length; o[i++] = OP.CAMERA; o[i++] = x; o[i++] = y; o[i++] = z; o[i++] = yaw; o[i++] = pitch; o[i++] = fov; o[i++] = near; this.length = i; this.ops++; } + light(x, y, z) { this.grow(4); const o = this.buffer; let i = this.length; o[i++] = OP.LIGHT; o[i++] = x; o[i++] = y; o[i++] = z; this.length = i; this.ops++; } place(mesh, x, y, z, yaw = 0, pitch = 0, roll = 0, scale = 1) { this.grow(10); const o = this.buffer; let i = this.length; o[i++] = OP.PLACE; o[i++] = mesh; o[i++] = x; o[i++] = y; o[i++] = z; o[i++] = yaw; o[i++] = pitch; o[i++] = roll; o[i++] = scale; o[i++] = this.a; this.length = i; this.ops++; } reset() { this.length = 0; this.ops = 0; this.overlay = false; } grow(extra) { @@ -72,6 +74,7 @@ export function readFrame(buffer, visit) { else if (op === OP.SHAPE) { const count = buffer[i]; const fill = buffer[i + 1]; const r = buffer[i + 2], g = buffer[i + 3], b = buffer[i + 4], a = buffer[i + 5]; i += 6; visit.shape?.(buffer.subarray(i, i + count * 2), fill, r, g, b, a); i += count * 2; } else if (op === OP.CAMERA) { visit.camera?.(buffer[i], buffer[i + 1], buffer[i + 2], buffer[i + 3], buffer[i + 4], buffer[i + 5], buffer[i + 6]); i += 7; } else if (op === OP.PLACE) { visit.place?.(buffer[i], buffer[i + 1], buffer[i + 2], buffer[i + 3], buffer[i + 4], buffer[i + 5], buffer[i + 6], buffer[i + 7], buffer[i + 8]); i += 9; } + else if (op === OP.LIGHT) { visit.light?.(buffer[i], buffer[i + 1], buffer[i + 2]); i += 3; } else return i; // unknown op: stop } return i; diff --git a/system/public/aesthetic.computer/lib/kidlisp-compile.mjs b/system/public/aesthetic.computer/lib/kidlisp-compile.mjs index 8a51e723aa..d423124edb 100644 --- a/system/public/aesthetic.computer/lib/kidlisp-compile.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp-compile.mjs @@ -118,7 +118,7 @@ const DELEGATE_TOP = new Set(["mesh", "tap", "draw", "lift", "once", "melody", " // Forms that take their arguments raw and evaluate what they need themselves; usable inside functions. const RAW_ANYWHERE = new Set(["hum", "tune", "hush", "pluck", "bell", "sub", "flute", "hat", "voice"]); // Drawing heads the GPU frame records (gpu-frame.mjs); ink runs on the CPU too, for its colour parsing. -const GPU_HEADS = new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place"]); +const GPU_HEADS = new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place", "light"]); const isTimerHead = (head) => typeof head === "number" || (typeof head === "string" && /^\d*\.?\d+s(?:!|\.{2,3})?$/.test(head)); const unquote = (s) => (typeof s === "string" && /^".*"$/s.test(s) ? s.slice(1, -1) : s); const NOTE = /^[a-g][#b]?[0-9]$/i; @@ -321,6 +321,7 @@ export function compileProgram(ast, lisp) { case "tri": if (n >= 6 && v.slice(0, 6).every((x) => typeof x === "number")) { frame.tri(v[0], v[1], v[2], v[3], v[4], v[5], v[6] === "outline" || /^outline/.test(String(v[6] ?? "")) ? 0 : fill); return; } break; case "shape": if (n >= 6 && n % 2 === 0 && v.every((x) => typeof x === "number")) { frame.shape(v, fill); return; } break; case "camera": if (n >= 3 && v.slice(0, 3).every((x) => typeof x === "number")) { frame.camera(v[0], v[1], v[2], num(v[3]), num(v[4]), typeof v[5] === "number" ? v[5] : 60, typeof v[6] === "number" ? v[6] : 1); return; } break; + case "light": if (n >= 3 && v.slice(0, 3).every((x) => typeof x === "number")) { frame.light(v[0], v[1], v[2]); return; } break; case "place": { const mesh = lisp.meshes?.get(String(v[0])); if (mesh && n >= 4 && v.slice(1, 4).every((x) => typeof x === "number")) { if (!frame.meshes.has(mesh.id)) frame.defineMesh(mesh.id, mesh); frame.place(mesh.id, v[1], v[2], v[3], num(v[4]), num(v[5]), num(v[6]), typeof v[7] === "number" ? v[7] : 1); } return; } } // Not a shape the frame takes (a named colour fill, text): the CPU draws it, over the frame. diff --git a/system/public/aesthetic.computer/lib/kidlisp-emit.mjs b/system/public/aesthetic.computer/lib/kidlisp-emit.mjs index 121af18ed6..423346a0ee 100644 --- a/system/public/aesthetic.computer/lib/kidlisp-emit.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp-emit.mjs @@ -24,7 +24,7 @@ const COMPARE_NAMES = { ">": ">", "<": "<", "=": "===" }; const SCREEN = { width: "(api.screen?.width ?? 0)", w: "(api.screen?.width ?? 0)", height: "(api.screen?.height ?? 0)", h: "(api.screen?.height ?? 0)", frame: "(api.paintCount || 0)", f: "(api.paintCount || 0)" }; const DELEGATE_TOP = new Set(["mesh", "tap", "draw", "lift", "once", "melody", "clock", "later", "jump", "hop", "delay", "trans", "net", "source", "choose", "?", "bake", "embed", "fps", "resolution", "die", "mic", "speaker", "overtone", "amplitude"]); const RAW_ANYWHERE = new Set(["hum", "tune", "hush", "pluck", "bell", "sub", "flute", "hat", "voice"]); -const GPU_HEADS = new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place"]); +const GPU_HEADS = new Set(["wipe", "ink", "line", "box", "circle", "oval", "tri", "shape", "write", "plot", "point", "camera", "place", "light"]); const isTimerHead = (head) => typeof head === "number" || (typeof head === "string" && /^\d*\.?\d+s(?:!|\.{2,3})?$/.test(head)); const unquote = (s) => (typeof s === "string" && /^".*"$/s.test(s) ? s.slice(1, -1) : s); const lit = (v) => JSON.stringify(v); @@ -297,6 +297,7 @@ export function programHelpers(lisp) { case "tri": if (n >= 6 && numbers(v, 6)) { frame.tri(v[0], v[1], v[2], v[3], v[4], v[5], outline(v[6]) ? 0 : fill); return; } break; case "shape": if (n >= 6 && n % 2 === 0 && numbers(v, n)) { frame.shape(v, fill); return; } break; case "camera": if (n >= 3 && numbers(v, 3)) { frame.camera(v[0], v[1], v[2], G(v[3]), G(v[4]), typeof v[5] === "number" ? v[5] : 60, typeof v[6] === "number" ? v[6] : 1); return; } break; + case "light": if (n >= 3 && numbers(v, 3)) { frame.light(v[0], v[1], v[2]); return; } break; case "place": { const mesh = lisp.meshes?.get(String(v[0])); if (mesh && n >= 4 && typeof v[1] === "number" && typeof v[2] === "number" && typeof v[3] === "number") { if (!frame.meshes.has(mesh.id)) frame.defineMesh(mesh.id, mesh); frame.place(mesh.id, v[1], v[2], v[3], G(v[4]), G(v[5]), G(v[6]), typeof v[7] === "number" ? v[7] : 1); } return; } } frame.overlay = true; diff --git a/system/public/aesthetic.computer/lib/kidlisp-mesh.mjs b/system/public/aesthetic.computer/lib/kidlisp-mesh.mjs index 4dc2db09ee..77ed8f177b 100644 --- a/system/public/aesthetic.computer/lib/kidlisp-mesh.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp-mesh.mjs @@ -16,8 +16,32 @@ // 22-23 depth base and slope; 24-26 light direction. export const DEPTH_BASE = -1.4, DEPTH_SLOPE = 2.8 / 16000, NEAR = 1; -export const LIGHT = normalize([-0.42, 1, -0.28]); -function normalize(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; } +export const LIGHT = normalize([-0.42, -1, -0.28]); // the sun shines down and a little to the left and back (y is up here) +export const AMBIENT = 0.34; // the light model: ambient + diffuse, one sun +export function normalize(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; } +// How much light a face with this normal gets from the sun in m[24..26]: +// ambient, plus diffuse from the side the sun shines from, plus a little +// from below (the floor's bounce) so undersides are not black. The same +// number on every host: the projector applies it, and a host that lights +// its own meshes (the Xbox) is handed pre-lit colours with its sun off. +export function lightFor(nx, ny, nz, m) { + const lx = m[24], ly = m[25], lz = m[26]; + const diffuse = Math.max(0, -(nx * lx + ny * ly + nz * lz)); + const bounce = Math.max(0, -ny) * 0.12; + return Math.min(1.25, AMBIENT + (1 - AMBIENT) * diffuse + bounce); +} +export const HOST_SUN_OFF = 0.72; // what the Xbox multiplies by when its light is zero +// A world-space mesh with the lighting baked into its face colours, for a +// host that lights by itself: its sun is set to zero and these colours are +// divided by what it then multiplies by. +export function bakeLighting(world, m) { + const faces = new Float32Array(world.faces); + for (let f = 0; f < faces.length; f += 10) { + const light = lightFor(faces[f + 7], faces[f + 8], faces[f + 9], m) / HOST_SUN_OFF; + faces[f + 4] = Math.min(255, Math.round(faces[f + 4] * light)); faces[f + 5] = Math.min(255, Math.round(faces[f + 5] * light)); faces[f + 6] = Math.min(255, Math.round(faces[f + 6] * light)); + } + return { verts: world.verts, faces }; +} // ---- building --------------------------------------------------------------- // forms: [["cube", w, h, d, r, g, b] | ["cube", x, y, z, w, h, d, r, g, b] | @@ -153,8 +177,8 @@ export function projectMesh(m, world, emitOrList, alpha = 255) { if (poly.length < 3) continue; poly = clip(poly, (p) => p[2] - near); // near plane, view space if (poly.length < 3) continue; - const light = 0.72 + Math.max(0, -(nx * m[24] + ny * m[25] + nz * m[26])) * 0.28; - const r = Math.round(faces[f + 4] * light), g = Math.round(faces[f + 5] * light), b = Math.round(faces[f + 6] * light); + const light = lightFor(nx, ny, nz, m); + const r = Math.min(255, Math.round(faces[f + 4] * light)), g = Math.min(255, Math.round(faces[f + 5] * light)), b = Math.min(255, Math.round(faces[f + 6] * light)); let depthSum = 0; let screen = poly.map((p) => { const k = m[14] + (m[15] / p[2] - m[14]) * m[16]; depthSum += p[2]; return [m[12] + p[0] * k, m[13] - p[1] * k, Math.max(-1.499, Math.min(1.4, m[22] + p[2] * m[23]))]; }); screen = clip(screen, (p) => p[0] - gl); if (screen.length < 3) continue; // the guard band, screen space diff --git a/system/public/aesthetic.computer/lib/kidlisp.mjs b/system/public/aesthetic.computer/lib/kidlisp.mjs index e694247b14..5888d89efb 100644 --- a/system/public/aesthetic.computer/lib/kidlisp.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp.mjs @@ -46,7 +46,7 @@ import { checkPackMode, getPackMode } from "./pack-mode.mjs"; import { log } from "./logs.mjs"; import { captureFrame, formatTimestamp, generateFilename } from "./frame-capture.mjs"; import { compileProgram, runKernelOverPool, runKernelOnGPU, kernelBackend } from "./kidlisp-compile.mjs"; -import { buildMesh, sceneCamera, placeMesh, projectMesh } from "./kidlisp-mesh.mjs"; +import { buildMesh, sceneCamera, placeMesh, projectMesh, normalize } from "./kidlisp-mesh.mjs"; import { compileKernel } from "./kidlisp-kernel.mjs"; import { GpuFrame } from "./gpu-frame.mjs"; @@ -66,7 +66,7 @@ const KIDLISP_FUNCTIONS = new Set([ // Pools, hums, console input (kidlisp/PIECE-IL.md) "pool", "spawn", "each", "kill", "alive", "empty", "rank", "kernel", "run", "gpu", "set", "uniform", "hum", "tune", "hush", "key", "pad", "else", // The 3D layer (kidlisp-mesh.mjs): geometry once, a camera and placements per frame - "mesh", "camera", "place", + "mesh", "camera", "place", "light", // Output "print", "debug", "log", "console", // Math @@ -5919,6 +5919,15 @@ class KidLisp { camera: (api, args = [], env) => { const v = args.map((a) => numArg(api, env, a, 0)); this.camera27 = sceneCamera(v[0] ?? 0, v[1] ?? 0, v[2] ?? 0, v[3] ?? 0, v[4] ?? 0, args.length > 5 ? v[5] : 60, api.screen?.width ?? 0, api.screen?.height ?? 0, this.camera27 || new Float32Array(27), args.length > 6 ? v[6] : 1); + if (this.lightDir) { this.camera27[24] = this.lightDir[0]; this.camera27[25] = this.lightDir[1]; this.camera27[26] = this.lightDir[2]; } + return 0; + }, + // (light x y z): the direction the sun shines towards. The camera carries it. + light: (api, args = [], env) => { + const v = normalize(args.slice(0, 3).map((a) => numArg(api, env, a, 0))); + this.lightDir = v; + if (this.gpuActive && this.gpuFrame) { this.gpuFrame.light(v[0], v[1], v[2]); return 0; } + if (this.camera27) { this.camera27[24] = v[0]; this.camera27[25] = v[1]; this.camera27[26] = v[2]; } return 0; }, // (place name x y z [yaw pitch roll scale]): a mesh at a pose. On the GPU diff --git a/tests/kidlisp-mesh.test.mjs b/tests/kidlisp-mesh.test.mjs index 7fee04ae36..3a0a66d936 100644 --- a/tests/kidlisp-mesh.test.mjs +++ b/tests/kidlisp-mesh.test.mjs @@ -26,7 +26,7 @@ test('a cube in front of the camera projects inside the viewport, lit, far faces const tris = []; const faces = projectMesh(cam, world, (...t) => tris.push(t)); assert.equal(faces, 1, 'only the facing side of a cube seen head-on survives culling'); - for (const t of tris) { for (const i of [0, 2, 4]) assert.ok(t[i] > 150 && t[i] < 250, 'x inside'); for (const i of [1, 3, 5]) assert.ok(t[i] > 100 && t[i] < 200, 'y inside'); assert.ok(t[7] <= 255 && t[7] >= 183, 'lit colour'); } + for (const t of tris) { for (const i of [0, 2, 4]) assert.ok(t[i] > 150 && t[i] < 250, 'x inside'); for (const i of [1, 3, 5]) assert.ok(t[i] > 100 && t[i] < 200, 'y inside'); assert.ok(t[7] <= 255 && t[7] >= Math.floor(255 * 0.34), 'lit colour: at least the ambient share'); } // a second cube behind the first is emitted before it const far = placeMesh(buildMesh([['cube', 10, 10, 10, 255, 0, 0]]), 0, 0, 90); const order = [];