diff --git a/xbox/live/object-lisp.mjs b/xbox/live/object-lisp.mjs index 78f8303352..628e2ce79b 100644 --- a/xbox/live/object-lisp.mjs +++ b/xbox/live/object-lisp.mjs @@ -146,9 +146,11 @@ const inks = { white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128], const maxDepth = 16; const owner = -1; // a read of the owner's pose, which moves every tick const shapes = { tri: 9, quad: 12, disc: 1, hoop: 2, band: 2, capsule: 7, line: 6 }; +// Flat shapes: object-space anchors, projected here, drawn as 2D ops. +const flats = { ball: 4, limb: 7, ring: 2, drum: 3, stroke: 7, plate: 9, slab: 6 }; const forms = new Set(["def", "let", "if", "repeat", "ink", "glow", "move", "rotate", - "scale", "radial", "mirror", "revolve"]); -const isStatement = (f) => Array.isArray(f) && (forms.has(f[0]) || f[0] in shapes); + "scale", "radial", "mirror", "revolve", "outline", "nudge", "toward"]); +const isStatement = (f) => Array.isArray(f) && (forms.has(f[0]) || f[0] in shapes || f[0] in flats); const union = (...sets) => { const out = new Set(); for (const s of sets) for (const x of s) out.add(x); return out; }; export function compile(source, name = "object") { @@ -159,7 +161,7 @@ export function compile(source, name = "object") { const moving = objectInputs.map((n) => !switches.has(n)); moving[detailSlot] = false; const isSwitch = (slot) => slot >= 0 && slot < objectInputs.length && switches.has(objectInputs[slot]); - const ticks = (reads) => { for (const r of reads) if (r === owner || moving[r]) return true; return false; }; + const ticks = (reads) => { for (const r of reads) if (r < 0 || moving[r]) return true; return false; }; const parts = []; // A scope maps a name to a slot or a constant (a `def`). @@ -215,7 +217,7 @@ export function compile(source, name = "object") { const inner = { names: {}, up: scope }; const nodes = [], entries = []; for (const form of list) { - const entry = { ink: ctx.ink, glow: ctx.glow }, first = parts.length; + const entry = { ink: ctx.ink, glow: ctx.glow, edge: ctx.edge, nudge: ctx.nudge }, first = parts.length; const node = statement(form, inner, depth, ctx); if (node) { node.parts = [first, parts.length]; nodes.push(node); entries.push(entry); } } @@ -252,20 +254,26 @@ export function compile(source, name = "object") { for (const b of bound) free.delete(b); for (const r of free) if (!isSwitch(r) && r !== detailSlot) return null; if (inkIn(group) && !entry.ink) return null; + // An outline or nudge that moves per tick can't be baked into it either. + if (!entry.edge || entry.nudge === null) return null; // Parts inside this one are baked into it, never emitted on their own. for (const n of group) for (let i = n.parts[0]; i < n.parts[1]; i++) parts[i].inner = true; const part = { depth, runs: group.map((n) => n.run), lets: group.filter((n) => n.binds).map((n) => n.run), switches: [...free].filter(isSwitch), detail: free.has(detailSlot), - ink: entry.ink || [255, 255, 255], glow: entry.glow, variants: null }; + ink: entry.ink || [255, 255, 255], glow: entry.glow, edge: entry.edge, nudge: entry.nudge, variants: null }; parts.push(part); const { runs, lets } = part, at = depth * 13; return (s) => { - if (!s.model) { for (let k = 0; k < runs.length; k++) runs[k](s); return; } + if (!part.variants || s.rec || (part.meshed && !s.model) || (part.sketched && !s.sketch)) { + for (let k = 0; k < runs.length; k++) runs[k](s); + return; + } for (let k = 0; k < lets.length; k++) lets[k](s); // what follows may read them let index = 0; for (let k = 0; k < part.switches.length; k++) if (s.v[part.switches[k]] >= .5) index |= 1 << k; const v = part.variants[index]; if (v.levels[0] >= 0) s.model(v.radius, v.levels[0], v.levels[1], v.levels[2], s.m, at); + if (v.shapes >= 0) s.sketch(v.shapes, s.m, at); s.r = v.ink[0]; s.g = v.ink[1]; s.b = v.ink[2]; }; } @@ -296,7 +304,7 @@ export function compile(source, name = "object") { } // A body that might run zero times or twice leaves the ink unknown after. const maybe = (ctx, run) => { - const inside = run({ ink: ctx.ink, glow: ctx.glow }); + const inside = run({ ...ctx }); if (inside.inkSets) ctx.ink = null; return inside; }; @@ -411,6 +419,74 @@ export function compile(source, name = "object") { } } }; } + case "outline": { + // (outline w [r g b] body…): the body's flat shapes drawn with an ink + // edge w world units wide, sized where the scope starts. + const lead = rest.findIndex((a) => isStatement(a)); + const opening = rest.slice(0, lead < 0 ? rest.length : lead); + if (opening.length !== 1 && opening.length !== 4) fail("outline wants w, or w r g b", form); + const reads = new Set(), [w, r, g, b] = opening.map((a) => num(a, scope, reads)); + const known = opening.map((a) => expr(a, scope)), wasEdge = ctx.edge; + ctx.edge = !wasEdge || !known.every((e) => "value" in e) ? null + : [known[0].value, ...(known.length === 4 ? known.slice(1).map((e) => e.value) : wasEdge.slice(1))]; + const inside = body(rest.slice(opening.length), scope, depth, ctx), run = inside.run; + ctx.edge = wasEdge; + return { ...inside, reads: union(reads, inside.reads), run: (s) => { + const was = s.outline.slice(); + // Baked, the width stays in world units and the host sizes it. + s.outline[0] = s.sketching ? w(s) * frameSize(s.m, depth * 13) : w(s) * scaleAt(s, depth * 13); + if (r) { s.outline[1] = r(s); s.outline[2] = g(s); s.outline[3] = b(s); } + run(s); + s.outline.splice(0, 4, ...was); + } }; + } + case "nudge": { + // (nudge d body…): the body's flat shapes d world units further back, + // to settle what covers what where two shapes share a depth. + const reads = new Set(), d = num(rest[0], scope, reads); + const known = expr(rest[0], scope), wasNudge = ctx.nudge; + ctx.nudge = wasNudge === null || !("value" in known) ? null : wasNudge + known.value; + const inside = body(rest.slice(1), scope, depth, ctx), run = inside.run; + ctx.nudge = wasNudge; + return { ...inside, reads: union(reads, inside.reads), run: (s) => { + const was = s.nudge; + s.nudge += d(s); + run(s); + s.nudge = was; + } }; + } + case "toward": { + // (toward axis body…): the body on whichever side of that axis faces + // the camera, so a wheel shows the face you can see. + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("toward wants x, y or z first", form); + if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form); + const inside = body(rest.slice(1), scope, depth + 1, ctx), run = inside.run; + const from = depth * 13, to = from + 13; + const flip = (m) => { for (let k = 0; k < 3; k++) m[to + 3 + axis * 3 + k] *= -1; m[to + 12] *= -1; }; + return { ...inside, run: (s) => { + const m = s.m; + if (s.sketching) { + // Baked: both faces, each marked one-sided, and the host shows the + // one turned its way. + for (const side of [1, -1]) { + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + if (side < 0) flip(m); + const was = s.facing; + s.facing = [m[to + 3 + axis * 3], m[to + 4 + axis * 3], m[to + 5 + axis * 3]]; + run(s); + s.facing = was; + } + return; + } + const V = s.view; + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + let away = 0; + for (let k = 0; k < 3; k++) away += m[to + 3 + axis * 3 + k] * (m[to + k] - V[k]); + if (away > 0) flip(m); + run(s); + } }; + } case "revolve": { // (revolve axis [turn] r h r h …): a closed profile turned about the axis. const axis = "xyz".indexOf(rest[0]); @@ -424,6 +500,19 @@ export function compile(source, name = "object") { } }; } } + if (head in flats) { + if (rest.length < flats[head]) fail(`${head} wants ${flats[head]} arguments`, form); + const axial = head === "ring" || head === "drum"; + const axis = axial ? "xyz".indexOf(rest[0]) : -1; + if (axial && axis < 0) fail(`${head} wants x, y or z first`, form); + const reads = new Set(), args = rest.slice(axial ? 1 : 0).map((a) => num(a, scope, reads)); + const values = new Float64Array(args.length), draw = flatShapes[head], record = recorders[head]; + return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => { + for (let i = 0; i < args.length; i++) values[i] = args[i](s); + if (s.sketching) record(s, depth * 13, values, axis); + else { inkUp(s); draw(s, depth * 13, values, axis); } + } }; + } if (head in shapes) { const want = shapes[head]; if (rest.length < want) fail(`${head} wants ${want} numbers`, form); @@ -438,9 +527,13 @@ export function compile(source, name = "object") { fail(`unknown form \`${head}\``, form); } - const run = body(program, top, 0, { ink: [255, 255, 255], glow: false }).run; + // What the compile knows is in effect as it walks: the ink, glow, and the + // ink edge and nudge a part would bake with (null when they move per tick). + const run = body(program, top, 0, { ink: [255, 255, 255], glow: false, edge: [0, 24, 20, 30], nudge: 0 }).run; const state = { v: new Float64Array(slots), m: new Float64Array(maxDepth * 13), - r: 255, g: 255, b: 255, glow: false, face: null, model: null, owner: null, rec: null }; + r: 255, g: 255, b: 255, glow: false, face: null, model: null, owner: null, rec: null, + out: null, view: null, nudge: 0, outline: [0, 24, 20, 30], sketch: null, sketching: null, facing: null, + inked: [0, 0, 0, 0] }; // Bake every part: per switch value, per level, run once into a mesh. Twin // meshes (a level that didn't change anything) share one handle. @@ -450,13 +543,19 @@ export function compile(source, name = "object") { o.vertices.every((x, i) => x === mesh.vertices[i]) && o.faces.every((x, i) => x === mesh.faces[i])); return same >= 0 ? same : meshes.push(mesh) - 1; }; + const sketches = []; + const keep = (shapes) => { + const same = sketches.findIndex((o) => o.count === shapes.count && o.records.length === shapes.records.length && + o.records.every((x, i) => x === shapes.records[i])); + return same >= 0 ? same : sketches.push(shapes) - 1; + }; const outer = parts.filter((part) => !part.inner); for (const part of outer) { part.variants = []; for (let index = 0; index < 1 << part.switches.length; index++) { part.switches.forEach((slot, k) => { state.v[slot] = (index >> k) & 1; }); const levels = []; - let radius = 0, ink = part.ink; + let radius = 0, ink = part.ink, shapes = -1; for (let level = 0; level < objectLevels; level++) { if (level && !part.detail) { levels.push(levels[0]); continue; } state.v[detailSlot] = level; @@ -464,14 +563,24 @@ export function compile(source, name = "object") { [state.r, state.g, state.b] = part.ink; state.glow = part.glow; state.rec = builder(); + state.sketching = sketcher(); + state.outline.splice(0, 4, ...part.edge); + state.nudge = part.nudge; + state.facing = null; for (const step of part.runs) step(state); - const mesh = state.rec.done(); - state.rec = null; - if (!level) ink = [state.r, state.g, state.b]; + const mesh = state.rec.done(), sketch = state.sketching.done(); + state.rec = state.sketching = null; + if (!level) { + ink = [state.r, state.g, state.b]; + // Flat shapes don't change with level: the host picks their sides. + shapes = sketch.count ? keep(sketch) : -1; + } radius = Math.max(radius, mesh.radius); levels.push(mesh.count ? store(mesh) : -1); } - part.variants.push({ levels, radius, ink }); + part.meshed ||= levels[0] >= 0; + part.sketched ||= shapes >= 0; + part.variants.push({ levels, radius, ink, shapes }); } } state.v.fill(0); @@ -492,10 +601,18 @@ export function compile(source, name = "object") { state.glow = false; state.face = typeof out === "function" ? out : out.face; state.model = typeof out === "function" ? null : out.model || null; + state.sketch = typeof out === "function" ? null : out.sketch || null; state.owner = inputs.owner || null; + state.out = out; + state.view = out.view || null; + state.nudge = 0; + state.outline[0] = 0; + state.inked[0] = 0; run(state); + if (state.inked[0]) out.outline(0, 0, 0, 0); // no ink edge left on for what follows } object.meshes = meshes; + object.sketches = sketches; object.parts = outer.length; return object; } @@ -697,3 +814,191 @@ function rod(s, at, v, width, sides) { v[3] + bx, v[4] + by, v[5] + bz, v[3] + ax, v[4] + ay, v[5] + az); } } + +// ——— flat shapes ——— +// Anchors go through the camera the frame program already carries (the +// CAMERA op's 24 numbers, `out.view`), by frame-vm's own projection, and come +// out as 2D ops with one flat depth each: out.disc, out.capsule, +// out.ellipse, out.plate, out.outline. No lighting; ink is the colour. + +const seen = new Float64Array(4 * 16); +// Project a point of the current frame: x, y, depth, and px per world unit. +function see(s, at, x, y, z, o) { + put(s.m, at, 0, x, y, z); + const V = s.view; + const dx = world[0] - V[0], dy = world[1] - V[1], dz = world[2] - V[2]; + const vz = dx * V[9] + dy * V[10] + dz * V[11]; + if (vz < V[19]) return false; + const vx = dx * V[3] + dy * V[4] + dz * V[5], vy = dx * V[6] + dy * V[7] + dz * V[8]; + const k = V[14] + (V[15] / vz - V[14]) * V[16]; + seen[o] = V[12] + vx * k; seen[o + 1] = V[13] - vy * k; seen[o + 2] = vz; seen[o + 3] = k; + return true; +} +const depthOf = (s, vz) => { + const z = (vz + s.nudge) * s.view[17] + s.view[18]; + return z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z; +}; +// How big a unit of this frame is in world units (its axes' mean length). +function frameSize(m, at) { + const ax = m[at + 3], ay = m[at + 4], az = m[at + 5], bx = m[at + 6], by = m[at + 7], bz = m[at + 8]; + const cx = m[at + 9], cy = m[at + 10], cz = m[at + 11]; + return Math.cbrt(Math.abs(ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx))); +} +// Px per unit of this frame at its origin. +function scaleAt(s, at) { return see(s, at, 0, 0, 0, 0) ? seen[3] * frameSize(s.m, at) : 0; } +// A circle about `axis`, `along` it from the frame's origin, as its centre +// (seen 0) and two conjugate half-axes (the points at seen 4 and seen 8). +function circle(s, at, axis, r, along = 0) { + const u = (axis + 1) % 3, w = (axis + 2) % 3, c = [0, 0, 0]; + c[axis] = along; + const p = c.slice(), q = c.slice(); + p[u] = r; q[w] = r; + return see(s, at, c[0], c[1], c[2], 0) && see(s, at, p[0], p[1], p[2], 4) && see(s, at, q[0], q[1], q[2], 8); +} +// A sketch being baked: records in the SHAPES layout (frame-vm.mjs, op 18), +// anchors in the part's own space. +function sketcher() { + const records = []; + let count = 0; + return { + add(s, kind, ...geometry) { + const f = s.facing || [0, 0, 0]; + records.push(kind, s.outline[0], s.outline[1], s.outline[2], s.outline[3], s.nudge, + s.r, s.g, s.b, f[0], f[1], f[2], ...geometry); + count++; + }, + done: () => ({ count, records: Float64Array.from(records) }), + }; +} +const SHAPE = { ball: 1, limb: 2, ring: 3, plate: 4, drum: 5 }; +// A point of the frame, in the part's space (baking) — into world[o…]. +const at3 = (s, at, x, y, z) => { put(s.m, at, 0, x, y, z); return [world[0], world[1], world[2]]; }; +// An axis of the frame times a length: a vector in the part's space. +const axis3 = (s, at, axis, length) => [0, 1, 2].map((k) => s.m[at + 3 + axis * 3 + k] * length); +const recorders = { + ball: (s, at, v) => s.sketching.add(s, SHAPE.ball, ...at3(s, at, v[0], v[1], v[2]), v[3] * frameSize(s.m, at)), + limb: (s, at, v) => s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[0], v[1], v[2]), ...at3(s, at, v[3], v[4], v[5]), + v[6] * frameSize(s.m, at)), + ring: (s, at, v, axis) => s.sketching.add(s, SHAPE.ring, ...at3(s, at, 0, 0, 0), + ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0])), + drum: (s, at, v, axis) => s.sketching.add(s, SHAPE.drum, ...at3(s, at, 0, 0, 0), + ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0]), ...axis3(s, at, axis, v[1] / 2)), + stroke: (s, at, v) => { + for (let i = 1; i + 5 < v.length; i += 3) + s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[i], v[i + 1], v[i + 2]), ...at3(s, at, v[i + 3], v[i + 4], v[i + 5]), + v[0] / 2 * frameSize(s.m, at)); + }, + plate: (s, at, v) => { + const n = Math.min(16, Math.floor(v.length / 3)), points = []; + for (let i = 0; i < n; i++) points.push(...at3(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2])); + s.sketching.add(s, SHAPE.plate, n, ...points); + }, + // A box baked as its six faces, each one-sided: the host shows the three + // turned its way, each outlined, which reads as a drawn box. + slab: (s, at, v) => { + const lo = [v[0], v[1], v[2]], hi = [v[3], v[4], v[5]], was = s.facing; + for (let axis = 0; axis < 3; axis++) for (const end of [lo, hi]) { + const u = (axis + 1) % 3, w = (axis + 2) % 3, sign = end === hi ? 1 : -1; + const corner = (a, b) => { const c = [0, 0, 0]; c[axis] = end[axis]; c[u] = a ? hi[u] : lo[u]; c[w] = b ? hi[w] : lo[w]; return at3(s, at, ...c); }; + s.facing = axis3(s, at, axis, sign * Math.sign(hi[axis] - lo[axis] || 1)); + s.sketching.add(s, SHAPE.plate, 4, ...corner(0, 0), ...corner(1, 0), ...corner(1, 1), ...corner(0, 1)); + } + s.facing = was; + }, +}; +// The ink edge in effect, sent as an OUTLINE op only when a shape drawn this +// tick needs a different one than the host already has. +function inkUp(s) { + const o = s.outline, sent = s.inked; + if (o[0] === sent[0] && (!o[0] || (o[1] === sent[1] && o[2] === sent[2] && o[3] === sent[3]))) return; + sent[0] = o[0]; sent[1] = o[1]; sent[2] = o[2]; sent[3] = o[3]; + s.out.outline(o[0], o[1], o[2], o[3]); +} +const flatShapes = { + // (ball x y z r) + ball: (s, at, v) => { + if (!see(s, at, v[0], v[1], v[2], 0)) return; + // As an ELLIPSE, the fan a baked ball gets, so both paths draw it alike. + const rad = v[3] * seen[3] * frameSize(s.m, at); + s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), rad, 0, 0, rad, s.r, s.g, s.b); + }, + // (limb x1 y1 z1 x2 y2 z2 r): a stadium between two ends + limb: (s, at, v) => { + if (!see(s, at, v[0], v[1], v[2], 0) || !see(s, at, v[3], v[4], v[5], 4)) return; + s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2), + v[6] * (seen[3] + seen[7]) * frameSize(s.m, at), s.r, s.g, s.b); + }, + // (ring axis r): a circle about the axis, as its projected ellipse + ring: (s, at, v, axis) => { + if (!circle(s, at, axis, v[0])) return; + s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), seen[4] - seen[0], seen[5] - seen[1], + seen[8] - seen[0], seen[9] - seen[1], s.r, s.g, s.b); + }, + // (drum axis r width): a cylinder as its silhouette — the far end's + // ellipse, the band between the two ends' tangent points, the near end's + // ellipse — each at its own depth, so the near end covers the band. + drum: (s, at, v, axis) => { + const caps = []; + for (const side of [-1, 1]) { + if (!circle(s, at, axis, v[0], side * v[1] / 2)) return; + caps.push({ x: seen[0], y: seen[1], vz: seen[2], ax: seen[4] - seen[0], ay: seen[5] - seen[1], + bx: seen[8] - seen[0], by: seen[9] - seen[1] }); + } + caps.sort((p, q) => q.vz - p.vz); + const [far, near] = caps, cap = (c) => s.out.ellipse(c.x, c.y, depthOf(s, c.vz), c.ax, c.ay, c.bx, c.by, s.r, s.g, s.b); + cap(far); + // Where each end's ellipse runs parallel to the drum's length. + const dx = near.x - far.x, dy = near.y - far.y; + if (Math.hypot(dx, dy) > .5) { + const tangent = (c) => { + const t = Math.atan2(c.bx * dy - c.by * dx, c.ax * dy - c.ay * dx); + return [c.ax * Math.cos(t) + c.bx * Math.sin(t), c.ay * Math.cos(t) + c.by * Math.sin(t)]; + }; + const [fx, fy] = tangent(far), [nx, ny] = tangent(near); + s.out.plate(4, [far.x + fx, far.y + fy, near.x + nx, near.y + ny, near.x - nx, near.y - ny, + far.x - fx, far.y - fy], depthOf(s, (far.vz + near.vz) / 2), s.r, s.g, s.b); + } + cap(near); + }, + // (stroke w x y z x y z …): a thick polyline, w world units wide + stroke: (s, at, v) => { + for (let i = 1; i + 5 < v.length; i += 3) { + if (!see(s, at, v[i], v[i + 1], v[i + 2], 0) || !see(s, at, v[i + 3], v[i + 4], v[i + 5], 4)) continue; + s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2), + v[0] * (seen[3] + seen[7]) / 2 * frameSize(s.m, at), s.r, s.g, s.b); + } + }, + // (plate x y z …): a flat polygon through projected points + plate: (s, at, v) => { + const n = Math.min(16, Math.floor(v.length / 3)), points = []; + let vz = 0; + for (let i = 0; i < n; i++) { + if (!see(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2], 0)) return; + points.push(seen[0], seen[1]); + vz += seen[2]; + } + s.out.plate(n, points, depthOf(s, vz / n), s.r, s.g, s.b); + }, + // (slab x1 y1 z1 x2 y2 z2): a box as its silhouette — the hull of its + // eight projected corners, one flat plate. + slab: (s, at, v) => { + const corners = []; + let vz = 0; + for (let i = 0; i < 8; i++) { + if (!see(s, at, v[i & 1 ? 3 : 0], v[i & 2 ? 4 : 1], v[i & 4 ? 5 : 2], 0)) return; + corners.push([seen[0], seen[1]]); + vz += seen[2]; + } + const hull = convexHull(corners); + s.out.plate(hull.length, hull.flat(), depthOf(s, vz / 8), s.r, s.g, s.b); + }, +}; +// Andrew's monotone chain. +function convexHull(points) { + const p = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower = [], upper = []; + for (const q of p) { while (lower.length > 1 && cross(lower.at(-2), lower.at(-1), q) <= 0) lower.pop(); lower.push(q); } + for (const q of p.reverse()) { while (upper.length > 1 && cross(upper.at(-2), upper.at(-1), q) <= 0) upper.pop(); upper.push(q); } + return lower.slice(0, -1).concat(upper.slice(0, -1)); +} diff --git a/xbox/live/objects/monowheel-flat.lisp b/xbox/live/objects/monowheel-flat.lisp new file mode 100644 index 0000000000..316962d77b --- /dev/null +++ b/xbox/live/objects/monowheel-flat.lisp @@ -0,0 +1,51 @@ +; monowheel-flat — the freeskate onewheel, drawn flat: world-anchored 2D shapes +; with ink outlines, no lighting. The same rig as monowheel.lisp: x forward, +; y up, z to the rider's right, the axle at the origin, a 24 radius tire. +; Three parts bake — the shadow, the rolling wheel, the leaning deck — so a +; tick is three SKETCH ops, and the host projects and fills the shapes. +; Only the silhouettes (tire, deck) carry ink: the details sit on fills that +; already contrast, and every outline doubles what the host draws. + +def r 24 +def half 13 +def ink-edge 1.4 + +(let roll (/ distance r)) +(let squash (* .14 (max 0 (- 1 (* land 5))))) +(let rattle (* 2.5 (max 0 (- 1 (* hit 4))) (sin (* time 70)))) + +; the shadow stays flat on the ground, behind everything +(nudge 40 + (ink 14 12 20) + (move 0 (- r) 0 (scale 2.7 1 .8 (ring y 22)))) + +(move 0 (- r) rattle + (rotate x lean + (scale (+ 1 squash) (- 1 squash) 1 + (move 0 r 0 + ; the wheel rolls as one: the tire (round, so turning it changes + ; nothing), then the face you can see — rim, web, spokes, hub + (rotate z (- roll) + (ink 40 36 48) + (outline ink-edge 20 16 28 (drum z r (* half 2))) + (toward z + (move 0 0 half + (ink (mix 214 236 turbo) (mix 216 190 turbo) (mix 226 255 turbo)) + (ring z 17) + (ink 58 52 66) + (move 0 0 .2 (ring z 13.5)) + (ink (mix 232 190 turbo) (mix 72 90 turbo) (mix 130 255 turbo)) + ; spokes as flat bars: two triangles each where a round end costs eight + (plate -13 -1.4 .4 13 -1.4 .4 13 1.4 .4 -13 1.4 .4) + (plate -1.4 -13 .4 1.4 -13 .4 1.4 13 .4 -1.4 13 .4) + (ink 70 64 80) + (ball 0 0 .6 3.5)))) + ; one deck, fore to aft, the tire poking through it; lamps at the + ; ends, white ahead and red behind + (nudge 14 + (ink (mix 44 102 turbo) (mix 42 35 turbo) (mix 54 163 turbo)) + (outline ink-edge 20 16 28 (slab -60 2 -19 60 8 19))) + (ink 240 244 250) + (ball 62 5 0 3.2) + (ink 240 70 70) + (ball -62 5 0 3.2))))) diff --git a/xbox/live/tests/object-lisp.test.mjs b/xbox/live/tests/object-lisp.test.mjs index 2366ffadfb..c98bf86da7 100644 --- a/xbox/live/tests/object-lisp.test.mjs +++ b/xbox/live/tests/object-lisp.test.mjs @@ -6,7 +6,8 @@ import assert from "node:assert/strict"; import { readFile, readdir } from "node:fs/promises"; import test from "node:test"; import { compile, read, objectLight } from "../object-lisp.mjs"; -import { createFrameVm, FRAME_CAMERA, FRAME_WORLD, FRAME_ASSET, FRAME_MODEL } from "../frame-vm.mjs"; +import { createFrameVm, FRAME_CAMERA, FRAME_WORLD, FRAME_ASSET, FRAME_MODEL, FRAME_DISC, + FRAME_CAPSULE, FRAME_ELLIPSE, FRAME_PLATE, FRAME_OUTLINE, FRAME_SHAPES, FRAME_SKETCH } from "../frame-vm.mjs"; import { parse } from "../../../system/public/aesthetic.computer/lib/kidlisp.mjs"; const objects = new URL("../objects/", import.meta.url); @@ -168,18 +169,20 @@ const runProgram = (values) => { }; const asset = (handle, m) => [FRAME_ASSET, handle, m.vertices.length / 3, m.count, ...m.vertices, ...m.faces]; // One tick of an object into a frame program, as the game would write it — -// faces only, or baked parts as ASSET once plus MODEL — run by the game's web -// interpreter. Returns what reached the host and what the tick cost. +// faces only, or baked parts as ASSET/SHAPES once plus MODEL/SKETCH — run by +// the game's web interpreter. Returns what reached the host and the cost. function draw(object, inputs, { baked = true, eye = away(220) } = {}) { - const setup = baked ? object.meshes.flatMap((m, handle) => asset(handle, m)) : []; + const setup = baked ? [...object.meshes.flatMap((m, handle) => asset(handle, m)), + ...object.sketches.flatMap((k, handle) => [FRAME_SHAPES, handle, k.count, k.records.length, ...k.records])] : []; const tick = []; - const cost = { faces: 0, models: 0 }; + const cost = { faces: 0, models: 0, sketches: 0 }; const face = (...f) => { cost.faces++; tick.push(FRAME_WORLD, ...f); }; const model = (radius, h0, h1, h2, m, at) => { cost.models++; tick.push(FRAME_MODEL, radius, h0, h1, h2, ...m.subarray(at, at + 12), ...objectLight); }; - object(inputs, upright, baked ? { face, model } : face); + const sketch = (h, m, at) => { cost.sketches++; tick.push(FRAME_SKETCH, h, ...m.subarray(at, at + 12)); }; + object(inputs, upright, baked ? { face, model, sketch } : face); cost.floats = tick.length; return { drawn: runProgram([...setup, ...camera(eye), ...tick]), cost }; } @@ -282,3 +285,105 @@ test("a tick is closures and two MODEL ops, not a tree walk", (t) => { t.diagnostic(`monowheel: ${baked.toFixed(1)} µs a tick baked, ${plain.toFixed(1)} µs as faces`); assert.ok(baked < 5000, `${baked.toFixed(1)} µs a tick`); }); + +// ——— the flat monowheel: world-anchored 2D shapes with ink outlines ——— + +const flatSource = await readFile(new URL("monowheel-flat.lisp", objects), "utf8"); +const opCodes = { WORLD: FRAME_WORLD, DISC: FRAME_DISC, CAPSULE: FRAME_CAPSULE, ELLIPSE: FRAME_ELLIPSE, + PLATE: FRAME_PLATE, OUTLINE: FRAME_OUTLINE }; +// One tick of a flat object as the ops it sends, and as a frame program. +function flatTick(object, inputs, eye) { + const view = camera(eye).slice(1), ops = []; + const rec = (op) => (...a) => ops.push({ op, a: op === "PLATE" ? [a[0], ...a[1], ...a.slice(2)] : a }); + object(inputs, upright, { view, face: rec("WORLD"), disc: rec("DISC"), capsule: rec("CAPSULE"), + ellipse: rec("ELLIPSE"), plate: rec("PLATE"), outline: rec("OUTLINE") }); + ops.program = [FRAME_CAMERA, ...view, ...ops.flatMap((o) => [opCodes[o.op], ...o.a])]; + ops.numbers = ops.program.length - 25; + return ops; +} +const side = [0, -24, -220], front = [220, -24, 0]; +const ellipses = (ops) => ops.filter((o) => o.op === "ELLIPSE").map((o) => o.a); + +test("the flat monowheel rolls, leans and squashes", () => { + const wheel = compile(flatSource, "monowheel-flat"); + // Through the per-tick path (a host without SKETCH), where the JS projects. + // The spokes: the two bars in spoke pink. + const spokes = (distance) => flatTick(wheel, { distance }, side) + .filter((o) => o.op === "PLATE" && o.a.at(-3) === 232).map((o) => o.a.slice(1, 9)); + const close = (a, b) => a.every((s, i) => s.every((x, k) => Math.abs(x - b[i][k]) < 1e-6)); + const r = 24; + assert.ok(close(spokes(10), spokes(10 + Math.PI * 2 * r)), "a full lap later the spokes are back"); + assert.ok(!close(spokes(10), spokes(10 + r * Math.PI / 4)), "an eighth of a turn on, they are not"); + // The tire, seen from ahead (its two caps' midpoint): leaning right carries + // it to the rider's right, which from ahead is the screen's left. + const tire = (inputs) => { const [, a, b] = ellipses(flatTick(wheel, inputs, front)); return (a[0] + b[0]) / 2; }; + assert.ok(tire({ lean: .3 }) < tire({}) - 5, "leans right"); + assert.ok(tire({ lean: -.3 }) > tire({}) + 5, "leans left"); + // A landing: side on, the drum's near cap gets wider and shorter. + const cap = (inputs) => ellipses(flatTick(wheel, inputs, side))[2]; + const spans = (e) => [Math.hypot(e[3], e[4]), Math.hypot(e[5], e[6])].sort((a, b) => a - b); + const [round, squashed] = [spans(cap({})), spans(cap({ land: 0 }))]; + assert.ok(squashed[1] > round[1] * 1.05, "wider"); + assert.ok(squashed[0] < round[0] * .95, "shorter"); +}); + +test("flat shapes carry their own depth: near covers far, the visible face comes forward", () => { + const wheel = compile(flatSource, "monowheel-flat"); + // Three-quarter views from either side, where the tire shows its band. + for (const eye of [[-130, -80, -170], [130, -80, 170]]) { + const ops = flatTick(wheel, {}, eye); + const [shadow, far, near, rim] = ellipses(ops); + const band = ops.filter((o) => o.op === "PLATE")[0].a; + const bandDepth = band[1 + band[0] * 2]; + assert.ok(far[2] > bandDepth && bandDepth > near[2], "far cap, band, near cap"); + // The rim lies in the near cap's plane, not the far one's. + assert.ok(Math.abs(rim[2] - near[2]) < 1e-9 && rim[2] < far[2], "`toward` puts the face on the side the camera sees"); + assert.ok(shadow[2] > far[2], "the shadow lies behind the wheel"); + } +}); + +// The flat rule (OBJECT-DIALECT.md): a tick sends at most 60 numbers, and at +// every distance the host draws fewer triangles than for the lit wheel. +test("the flat monowheel keeps to its budget, under the lit one's", (t) => { + const flat = compile(flatSource, "monowheel-flat"), lit = compile(monowheelSource, "monowheel"); + assert.equal(flat.parts, 3, "shadow, wheel and deck bake; nothing is projected in JS"); + const near = draw(flat, { speed: 300, distance: 5 }); + assert.deepEqual([near.cost.sketches, near.cost.faces, near.cost.models], [3, 0, 0]); + assert.ok(near.cost.floats <= 60, `${near.cost.floats} numbers`); + const rows = [220, 600, 1500, 4000].map((d) => [d, draw(flat, { speed: 300 }, { eye: away(d) }).drawn.length, + draw(lit, { speed: 300 }, { eye: away(d) }).drawn.length]); + t.diagnostic(`flat tick ${near.cost.floats} numbers; triangles flat/lit by distance: ${rows.map(([d, f, l]) => `${d}: ${f}/${l}`).join(", ")}`); + for (const [d, f, l] of rows) assert.ok(f < l, `at ${d}: flat ${f} triangles, lit ${l}`); +}); + +// Baked and projected per tick, the flat wheel covers the same screen: the +// sketch the host draws is the shapes the JS path would have sent. +test("a baked sketch draws where the per-tick shapes do", () => { + const flat = compile(flatSource, "monowheel-flat"); + const box = (tris) => { + const b = [Infinity, Infinity, -Infinity, -Infinity]; + for (const t of tris) for (let v = 0; v < 9; v += 3) { + b[0] = Math.min(b[0], t[v]); b[1] = Math.min(b[1], t[v + 1]); b[2] = Math.max(b[2], t[v]); b[3] = Math.max(b[3], t[v + 1]); + } + return b; + }; + for (const [inputs, eye] of [[{ distance: 9 }, away(220)], [{ lean: .3, land: .05, turbo: 1 }, [-130, -80, -170]]]) { + const baked = box(draw(flat, inputs, { eye }).drawn), perTick = box(runProgram(flatTick(flat, inputs, eye).program)); + baked.forEach((x, i) => assert.ok(Math.abs(x - perTick[i]) < 4, `edge ${i}: baked ${x} per tick ${perTick[i]}`)); + } +}); + +// Reported, and bounded only loosely, as the lit timing is. +test("a flat tick is three SKETCH ops, cheaper than the lit tick", (t) => { + const flat = compile(flatSource, "monowheel-flat"), lit = compile(monowheelSource, "monowheel"); + const out = { view: camera(away(260)).slice(1), face() {}, model() {}, sketch() {} }; + const time = (object) => { + const start = performance.now(); + for (let i = 0; i < 4000; i++) object({ distance: i, time: i / 60, speed: 200 }, upright, out); + return (performance.now() - start) / 4000 * 1000; + }; + time(flat); time(lit); + const [f, l] = [time(flat), time(lit)]; + t.diagnostic(`a tick: flat ${f.toFixed(1)} µs, lit ${l.toFixed(1)} µs`); + assert.ok(f < 5000); +});