From c138d54930823c15a7aafd29100a8e01d51d4423 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:41:33 +0800 Subject: [PATCH 1/8] feat(playset): scene3d surface contract + client + sim host, math lib, foundation modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scene3d: closed write-only presentation vocabulary (ops.ts), retained guest mirror with batched pose flush (client.ts), renderless sim host with canonical serialization for goldens (sim.ts), - playset/math: clean-room three@0.161-compatible math subset - modules/math: WorldBasis, Vector3Utils, Scalar/Random/TimeUtils ports (Clock defaults to the virtual clock, never Date.now) - modules/physics: deterministic CollisionWorld (capsule resolve, raycasts, walkable tops) — the injected-Rapier replacement seam - loop.ts: fixed 1/60 game loop, hz-invariant via ticksPerFrame catch-up Co-Authored-By: Claude Fable 5 --- playset/ATTRIBUTION.md | 37 + playset/index.ts | 12 + playset/loop.ts | 53 ++ playset/math/color.ts | 338 ++++++++ playset/math/euler.ts | 153 ++++ playset/math/index.ts | 22 + playset/math/math-utils.ts | 104 +++ playset/math/matrix4.ts | 153 ++++ playset/math/quaternion.ts | 325 ++++++++ playset/math/vector3.ts | 314 +++++++ playset/modules/math/random-utils.ts | 54 ++ playset/modules/math/scalar-utils.ts | 47 ++ playset/modules/math/time-utils.ts | 60 ++ playset/modules/math/vector3-utils.ts | 55 ++ playset/modules/math/world-basis.ts | 344 ++++++++ playset/modules/physics/collision-world.ts | 492 +++++++++++ playset/scene3d/client.ts | 380 +++++++++ playset/scene3d/index.ts | 14 + playset/scene3d/ops.ts | 204 +++++ playset/scene3d/sim.ts | 679 ++++++++++++++++ playset/scene3d/viewport.ts | 41 + playset/test/foundation.test.ts | 165 ++++ playset/test/math.test.ts | 903 +++++++++++++++++++++ playset/test/scene3d-sim.test.ts | 392 +++++++++ tsconfig.json | 6 +- 25 files changed, 5346 insertions(+), 1 deletion(-) create mode 100644 playset/ATTRIBUTION.md create mode 100644 playset/index.ts create mode 100644 playset/loop.ts create mode 100644 playset/math/color.ts create mode 100644 playset/math/euler.ts create mode 100644 playset/math/index.ts create mode 100644 playset/math/math-utils.ts create mode 100644 playset/math/matrix4.ts create mode 100644 playset/math/quaternion.ts create mode 100644 playset/math/vector3.ts create mode 100644 playset/modules/math/random-utils.ts create mode 100644 playset/modules/math/scalar-utils.ts create mode 100644 playset/modules/math/time-utils.ts create mode 100644 playset/modules/math/vector3-utils.ts create mode 100644 playset/modules/math/world-basis.ts create mode 100644 playset/modules/physics/collision-world.ts create mode 100644 playset/scene3d/client.ts create mode 100644 playset/scene3d/index.ts create mode 100644 playset/scene3d/ops.ts create mode 100644 playset/scene3d/sim.ts create mode 100644 playset/scene3d/viewport.ts create mode 100644 playset/test/foundation.test.ts create mode 100644 playset/test/math.test.ts create mode 100644 playset/test/scene3d-sim.test.ts diff --git a/playset/ATTRIBUTION.md b/playset/ATTRIBUTION.md new file mode 100644 index 0000000..a3547c5 --- /dev/null +++ b/playset/ATTRIBUTION.md @@ -0,0 +1,37 @@ +# Attribution + +Substantial portions of `playset/modules/` are TypeScript ports of +**GameBlocks** (https://github.com/xt4d/GameBlocks), © 2026 Weihao Cheng +(xt4d), used under the MIT license reproduced below. Ported files carry a header identifying their +source module; behavioral changes beyond the Three.js→playset substitution +are called out in each header and in `summary.md`. + +`playset/math/` is an independent clean-room implementation of a subset of +the Three.js math API (method names and signatures mirror three@0.161 for +port compatibility; no Three.js source code was copied). + +## GameBlocks license (MIT) + +``` +MIT License + +Copyright (c) 2026 Weihao Cheng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/playset/index.ts b/playset/index.ts new file mode 100644 index 0000000..b34502f --- /dev/null +++ b/playset/index.ts @@ -0,0 +1,12 @@ +// @pocketjs/playset — game-mechanism playsets for Pocket. +// +// Structure (see SKILL.md for the copy-into-project workflow): +// math/ three-compatible math value types (Vector3, Quaternion, ...) +// scene3d/ the scene3d presentation surface (ops contract, client, viewport) +// modules/ the ported GameBlocks module library (motion, camera, gameplay, +// behavior, world, user-interface) — vendored per game project +// loop.ts fixed-step game loop on the virtual clock + +export * from "./math/index.ts"; +export * from "./scene3d/index.ts"; +export { createGameLoop, FIXED_DT, type GameInput, type GameLoopOptions } from "./loop.ts"; diff --git a/playset/loop.ts b/playset/loop.ts new file mode 100644 index 0000000..cf6f373 --- /dev/null +++ b/playset/loop.ts @@ -0,0 +1,53 @@ +// playset/loop.ts — the fixed-step game loop, wired to the virtual clock. +// +// GameBlocks demos run `FIXED_STEP = 1/60` state machines driven by rAF with +// dt clamping. Pocket already HAS the stronger version of that contract +// (DETERMINISM.md): one `frame()` per virtual frame, `60/hz` core ticks each. +// This helper folds a playset game into it: the sim ALWAYS steps at 1/60 s — +// on a low-Hz guest it steps 60/hz times per virtual frame — so the +// trajectory is identical at every simulationHz (the subsampling theorem +// extends from pixels to game state), input tapes replay byte-exact, and +// host-sim golden traces work unchanged. +// +// Usage (inside a component, e.g. the app root or a owner): +// +// createGameLoop({ +// step: (dt, input) => { game.step(state, input, dt); }, // 1/60 fixed +// render: () => { syncVisuals(state); scene.flush(); }, // once/frame +// }); + +import { onFrame, analogX, analogY } from "@pocketjs/framework/lifecycle"; +import { ticksPerFrame } from "@pocketjs/framework/clock"; + +/** The fixed simulation timestep (matches spec FIXED_DT: one core tick). */ +export const FIXED_DT = 1 / 60; + +export interface GameInput { + /** spec BTN bitmask as delivered to frame(). */ + buttons: number; + /** Analog nub, deadzoned, -1..1 (0 on stickless hosts). */ + analogX: number; + analogY: number; +} + +export interface GameLoopOptions { + /** Called `60/hz` times per virtual frame with dt = FIXED_DT, always. */ + step: (dt: number, input: GameInput) => void; + /** Called once per virtual frame AFTER the catch-up steps — push poses to + * the scene and flush here (presentation runs at the guest rate; the sim + * never skips or stretches a step). */ + render?: (input: GameInput) => void; +} + +/** Register the loop (component-scoped — unregisters with its owner). */ +export function createGameLoop(opts: GameLoopOptions): void { + const input: GameInput = { buttons: 0, analogX: 0, analogY: 0 }; + onFrame((buttons) => { + input.buttons = buttons; + input.analogX = analogX(); + input.analogY = analogY(); + const n = ticksPerFrame(); + for (let i = 0; i < n; i++) opts.step(FIXED_DT, input); + opts.render?.(input); + }); +} diff --git a/playset/math/color.ts b/playset/math/color.ts new file mode 100644 index 0000000..423f39c --- /dev/null +++ b/playset/math/color.ts @@ -0,0 +1,338 @@ +// playset/math/color.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. +// +// Color management matches three@0.161 defaults: `ColorManagement.enabled` +// is true and the working color space is Linear-sRGB, so `new Color(hex)`, +// `set(hex)`, `setStyle(...)`, `getHex()` and `getHexString()` convert +// through the standard sRGB transfer function exactly like three, while +// `setRGB`/`setHSL`/`getHSL`/`offsetHSL`/`lerp` operate directly in the +// working space. Set `ColorManagement.enabled = false` for raw sRGB +// components (same escape hatch as three). +// +// Scope note: CSS color keywords ('rebeccapurple', ...) are not supported in +// setStyle — no GameBlocks module uses them (hex numbers only). Hex, rgb()/ +// rgba() and hsl()/hsla() strings are supported. + +import { clamp, euclideanModulo, lerp } from "./math-utils.ts"; + +export const SRGBColorSpace = "srgb"; +export const LinearSRGBColorSpace = "srgb-linear"; +export type ColorSpace = typeof SRGBColorSpace | typeof LinearSRGBColorSpace; + +export type ColorRepresentation = Color | number | string; + +export interface HSL { + h: number; + s: number; + l: number; +} + +export interface RGB { + r: number; + g: number; + b: number; +} + +/** Standard sRGB EOTF: gamma-encoded component → linear. */ +export function SRGBToLinear(c: number): number { + return c < 0.04045 ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4); +} + +/** Standard sRGB OETF: linear component → gamma-encoded. */ +export function LinearToSRGB(c: number): number { + return c < 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 0.41666) - 0.055; +} + +export const ColorManagement = { + /** three@0.161 default: color management on. */ + enabled: true, + workingColorSpace: LinearSRGBColorSpace as ColorSpace, + + convert(color: RGB, sourceColorSpace: ColorSpace, targetColorSpace: ColorSpace): RGB { + if ( + this.enabled === false || + sourceColorSpace === targetColorSpace || + !sourceColorSpace || + !targetColorSpace + ) { + return color; + } + const fn = sourceColorSpace === SRGBColorSpace ? SRGBToLinear : LinearToSRGB; + color.r = fn(color.r); + color.g = fn(color.g); + color.b = fn(color.b); + return color; + }, + + toWorkingColorSpace(color: RGB, sourceColorSpace: ColorSpace): RGB { + return this.convert(color, sourceColorSpace, this.workingColorSpace); + }, + + fromWorkingColorSpace(color: RGB, targetColorSpace: ColorSpace): RGB { + return this.convert(color, this.workingColorSpace, targetColorSpace); + }, +}; + +/** Wikipedia HSL→RGB helper; p is the low value, q the high value. */ +function hue2rgb(p: number, q: number, t: number): number { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * 6 * (2 / 3 - t); + return p; +} + +/** + * An RGB color with components in the working color space (Linear-sRGB by + * default, matching three). Mutating methods return `this`. + */ +export class Color { + readonly isColor: true = true; + + r = 1; + g = 1; + b = 1; + + constructor(r?: ColorRepresentation, g?: number, b?: number) { + if (r === undefined) return; + if (g === undefined || b === undefined) { + this.set(r); + } else { + this.setRGB(r as number, g, b); + } + } + + set(color: ColorRepresentation): this; + set(r: number, g: number, b: number): this; + set(color: ColorRepresentation, g?: number, b?: number): this { + if (g !== undefined && b !== undefined) { + return this.setRGB(color as number, g, b); + } + if (typeof color === "object" && color.isColor) { + return this.copy(color); + } + if (typeof color === "number") { + return this.setHex(color); + } + if (typeof color === "string") { + return this.setStyle(color); + } + return this; + } + + setScalar(scalar: number): this { + this.r = scalar; + this.g = scalar; + this.b = scalar; + return this; + } + + setHex(hex: number, colorSpace: ColorSpace = SRGBColorSpace): this { + hex = Math.floor(hex); + this.r = ((hex >> 16) & 255) / 255; + this.g = ((hex >> 8) & 255) / 255; + this.b = (hex & 255) / 255; + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + setRGB(r: number, g: number, b: number, colorSpace: ColorSpace = ColorManagement.workingColorSpace): this { + this.r = r; + this.g = g; + this.b = b; + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + setHSL(h: number, s: number, l: number, colorSpace: ColorSpace = ColorManagement.workingColorSpace): this { + h = euclideanModulo(h, 1); + s = clamp(s, 0, 1); + l = clamp(l, 0, 1); + + if (s === 0) { + this.r = this.g = this.b = l; + } else { + const q = l <= 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + this.r = hue2rgb(p, q, h + 1 / 3); + this.g = hue2rgb(p, q, h); + this.b = hue2rgb(p, q, h - 1 / 3); + } + + ColorManagement.toWorkingColorSpace(this, colorSpace); + return this; + } + + /** + * Parses '#rgb'/'#rrggbb' hex strings and rgb()/rgba()/hsl()/hsla() + * functional notation. CSS color keywords are not supported (see header). + */ + setStyle(style: string, colorSpace: ColorSpace = SRGBColorSpace): this { + const fn = /^(\w+)\(([^)]*)\)$/.exec(style.trim()); + if (fn) { + const name = fn[1]!; + const components = fn[2]!; + let m: RegExpExecArray | null; + + switch (name) { + case "rgb": + case "rgba": + m = /^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // rgb(255, 0, 0) + return this.setRGB( + Math.min(255, parseInt(m[1]!, 10)) / 255, + Math.min(255, parseInt(m[2]!, 10)) / 255, + Math.min(255, parseInt(m[3]!, 10)) / 255, + colorSpace, + ); + } + m = /^\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // rgb(100%, 0%, 0%) + return this.setRGB( + Math.min(100, parseInt(m[1]!, 10)) / 100, + Math.min(100, parseInt(m[2]!, 10)) / 100, + Math.min(100, parseInt(m[3]!, 10)) / 100, + colorSpace, + ); + } + break; + case "hsl": + case "hsla": + m = /^\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+\s*)?$/.exec(components); + if (m) { + // hsl(120, 50%, 50%) + return this.setHSL( + parseFloat(m[1]!) / 360, + parseFloat(m[2]!) / 100, + parseFloat(m[3]!) / 100, + colorSpace, + ); + } + break; + } + return this; + } + + const hex = /^#([A-Fa-f\d]+)$/.exec(style.trim()); + if (hex) { + const digits = hex[1]!; + if (digits.length === 3) { + // #ff0 shorthand + return this.setRGB( + parseInt(digits.charAt(0), 16) / 15, + parseInt(digits.charAt(1), 16) / 15, + parseInt(digits.charAt(2), 16) / 15, + colorSpace, + ); + } + if (digits.length === 6) { + return this.setHex(parseInt(digits, 16), colorSpace); + } + } + return this; + } + + clone(): Color { + return new Color().copy(this); + } + + copy(color: Color): this { + this.r = color.r; + this.g = color.g; + this.b = color.b; + return this; + } + + getHex(colorSpace: ColorSpace = SRGBColorSpace): number { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + return ( + Math.round(clamp(_color.r * 255, 0, 255)) * 65536 + + Math.round(clamp(_color.g * 255, 0, 255)) * 256 + + Math.round(clamp(_color.b * 255, 0, 255)) + ); + } + + getHexString(colorSpace: ColorSpace = SRGBColorSpace): string { + return ("000000" + this.getHex(colorSpace).toString(16)).slice(-6); + } + + getHSL(target: HSL, colorSpace: ColorSpace = ColorManagement.workingColorSpace): HSL { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + const r = _color.r, g = _color.g, b = _color.b; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let hue = 0; + let saturation = 0; + const lightness = (min + max) / 2.0; + + if (min !== max) { + const delta = max - min; + saturation = lightness <= 0.5 ? delta / (max + min) : delta / (2 - max - min); + switch (max) { + case r: + hue = (g - b) / delta + (g < b ? 6 : 0); + break; + case g: + hue = (b - r) / delta + 2; + break; + case b: + hue = (r - g) / delta + 4; + break; + } + hue /= 6; + } + + target.h = hue; + target.s = saturation; + target.l = lightness; + return target; + } + + getRGB(target: RGB, colorSpace: ColorSpace = ColorManagement.workingColorSpace): RGB { + ColorManagement.fromWorkingColorSpace(_color.copy(this), colorSpace); + target.r = _color.r; + target.g = _color.g; + target.b = _color.b; + return target; + } + + /** Adds the given h/s/l deltas to this color (hue wraps, s/l clamp). */ + offsetHSL(h: number, s: number, l: number): this { + this.getHSL(_hsl); + return this.setHSL(_hsl.h + h, _hsl.s + s, _hsl.l + l); + } + + lerp(color: Color, alpha: number): this { + this.r += (color.r - this.r) * alpha; + this.g += (color.g - this.g) * alpha; + this.b += (color.b - this.b) * alpha; + return this; + } + + lerpColors(color1: Color, color2: Color, alpha: number): this { + this.r = lerp(color1.r, color2.r, alpha); + this.g = lerp(color1.g, color2.g, alpha); + this.b = lerp(color1.b, color2.b, alpha); + return this; + } + + multiplyScalar(s: number): this { + this.r *= s; + this.g *= s; + this.b *= s; + return this; + } + + equals(c: Color): boolean { + return c.r === this.r && c.g === this.g && c.b === this.b; + } +} + +// Module-level scratch temporaries (three's own pattern). +const _color = /*@__PURE__*/ new Color(); +const _hsl: HSL = { h: 0, s: 0, l: 0 }; diff --git a/playset/math/euler.ts b/playset/math/euler.ts new file mode 100644 index 0000000..28b2bdc --- /dev/null +++ b/playset/math/euler.ts @@ -0,0 +1,153 @@ +// playset/math/euler.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import { Matrix4 } from "./matrix4.ts"; +import { Quaternion } from "./quaternion.ts"; + +export type EulerOrder = "XYZ" | "YXZ" | "ZXY" | "ZYX" | "YZX" | "XZY"; + +/** + * Intrinsic Tait-Bryan angles in radians, applied in `order` (three + * convention; default 'XYZ'). Mutating methods return `this`. + */ +export class Euler { + static DEFAULT_ORDER: EulerOrder = "XYZ"; + + readonly isEuler: true = true; + + x: number; + y: number; + z: number; + order: EulerOrder; + + constructor(x = 0, y = 0, z = 0, order: EulerOrder = Euler.DEFAULT_ORDER) { + this.x = x; + this.y = y; + this.z = z; + this.order = order; + } + + set(x: number, y: number, z: number, order: EulerOrder = this.order): this { + this.x = x; + this.y = y; + this.z = z; + this.order = order; + return this; + } + + clone(): Euler { + return new Euler(this.x, this.y, this.z, this.order); + } + + copy(euler: Euler): this { + this.x = euler.x; + this.y = euler.y; + this.z = euler.z; + this.order = euler.order; + return this; + } + + /** + * Extracts euler angles from `m`, whose upper-3x3 is assumed to be a pure + * (unscaled) rotation matrix. Standard per-order extraction with the same + * gimbal-lock fallbacks as three. + */ + setFromRotationMatrix(m: Matrix4, order: EulerOrder = this.order): this { + const te = m.elements; + const m11 = te[0]!, m12 = te[4]!, m13 = te[8]!; + const m21 = te[1]!, m22 = te[5]!, m23 = te[9]!; + const m31 = te[2]!, m32 = te[6]!, m33 = te[10]!; + + switch (order) { + case "XYZ": + this.y = Math.asin(clamp(m13, -1, 1)); + if (Math.abs(m13) < 0.9999999) { + this.x = Math.atan2(-m23, m33); + this.z = Math.atan2(-m12, m11); + } else { + this.x = Math.atan2(m32, m22); + this.z = 0; + } + break; + case "YXZ": + this.x = Math.asin(-clamp(m23, -1, 1)); + if (Math.abs(m23) < 0.9999999) { + this.y = Math.atan2(m13, m33); + this.z = Math.atan2(m21, m22); + } else { + this.y = Math.atan2(-m31, m11); + this.z = 0; + } + break; + case "ZXY": + this.x = Math.asin(clamp(m32, -1, 1)); + if (Math.abs(m32) < 0.9999999) { + this.y = Math.atan2(-m31, m33); + this.z = Math.atan2(-m12, m22); + } else { + this.y = 0; + this.z = Math.atan2(m21, m11); + } + break; + case "ZYX": + this.y = Math.asin(-clamp(m31, -1, 1)); + if (Math.abs(m31) < 0.9999999) { + this.x = Math.atan2(m32, m33); + this.z = Math.atan2(m21, m11); + } else { + this.x = 0; + this.z = Math.atan2(-m12, m22); + } + break; + case "YZX": + this.z = Math.asin(clamp(m21, -1, 1)); + if (Math.abs(m21) < 0.9999999) { + this.x = Math.atan2(-m23, m22); + this.y = Math.atan2(-m31, m11); + } else { + this.x = 0; + this.y = Math.atan2(m13, m33); + } + break; + case "XZY": + this.z = Math.asin(-clamp(m12, -1, 1)); + if (Math.abs(m12) < 0.9999999) { + this.x = Math.atan2(m32, m22); + this.y = Math.atan2(m13, m11); + } else { + this.x = Math.atan2(-m23, m33); + this.y = 0; + } + break; + } + + this.order = order; + return this; + } + + setFromQuaternion(q: Quaternion, order: EulerOrder = this.order): this { + _matrix.makeRotationFromQuaternion(q); + return this.setFromRotationMatrix(_matrix, order); + } + + /** Re-expresses the same rotation in a different order (may introduce gimbal lock). */ + reorder(newOrder: EulerOrder): this { + _quaternion.setFromEuler(this); + return this.setFromQuaternion(_quaternion, newOrder); + } + + equals(euler: Euler): boolean { + return ( + euler.x === this.x && + euler.y === this.y && + euler.z === this.z && + euler.order === this.order + ); + } +} + +// Module-level scratch temporaries (three's own pattern). +const _matrix = /*@__PURE__*/ new Matrix4(); +const _quaternion = /*@__PURE__*/ new Quaternion(); diff --git a/playset/math/index.ts b/playset/math/index.ts new file mode 100644 index 0000000..e48a95c --- /dev/null +++ b/playset/math/index.ts @@ -0,0 +1,22 @@ +// playset/math/index.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented, not copied. Porting a GameBlocks module is a mechanical +// import swap: `import { Vector3 } from 'three'` → `from '../math/index.ts'`. + +export { Vector3, type BufferAttributeLike } from "./vector3.ts"; +export { Quaternion } from "./quaternion.ts"; +export { Matrix4 } from "./matrix4.ts"; +export { Euler, type EulerOrder } from "./euler.ts"; +export { + Color, + ColorManagement, + LinearSRGBColorSpace, + LinearToSRGB, + SRGBColorSpace, + SRGBToLinear, + type ColorRepresentation, + type ColorSpace, + type HSL, + type RGB, +} from "./color.ts"; +export { DEG2RAD, MathUtils, RAD2DEG } from "./math-utils.ts"; diff --git a/playset/math/math-utils.ts b/playset/math/math-utils.ts new file mode 100644 index 0000000..9254758 --- /dev/null +++ b/playset/math/math-utils.ts @@ -0,0 +1,104 @@ +// playset/math/math-utils.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +export const DEG2RAD: number = Math.PI / 180; +export const RAD2DEG: number = 180 / Math.PI; + +/** Clamps `value` to the inclusive range [min, max]. */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** Linear interpolation from `x` to `y` by factor `t`. */ +export function lerp(x: number, y: number, t: number): number { + return (1 - t) * x + t * y; +} + +/** Inverse of lerp: the factor of `value` between `x` and `y` (0 when x === y). */ +export function inverseLerp(x: number, y: number, value: number): number { + if (x !== y) return (value - x) / (y - x); + return 0; +} + +/** Frame-rate independent exponential approach of `x` toward `y`. */ +export function damp(x: number, y: number, lambda: number, dt: number): number { + return lerp(x, y, 1 - Math.exp(-lambda * dt)); +} + +/** Maps `x` from range [a1, a2] into range [b1, b2]. */ +export function mapLinear(x: number, a1: number, a2: number, b1: number, b2: number): number { + return b1 + ((x - a1) * (b2 - b1)) / (a2 - a1); +} + +/** Euclidean (always-positive) modulo of n by m. */ +export function euclideanModulo(n: number, m: number): number { + return ((n % m) + m) % m; +} + +/** Triangle wave over [0, length] (default length 1). */ +export function pingpong(x: number, length = 1): number { + return length - Math.abs(euclideanModulo(x, length * 2) - length); +} + +/** Hermite smoothstep of `x` between `min` and `max`. */ +export function smoothstep(x: number, min: number, max: number): number { + if (x <= min) return 0; + if (x >= max) return 1; + x = (x - min) / (max - min); + return x * x * (3 - 2 * x); +} + +/** Perlin's smootherstep of `x` between `min` and `max`. */ +export function smootherstep(x: number, min: number, max: number): number { + if (x <= min) return 0; + if (x >= max) return 1; + x = (x - min) / (max - min); + return x * x * x * (x * (x * 6 - 15) + 10); +} + +export function degToRad(degrees: number): number { + return degrees * DEG2RAD; +} + +export function radToDeg(radians: number): number { + return radians * RAD2DEG; +} + +/** Random integer in the inclusive interval [low, high]. */ +export function randInt(low: number, high: number): number { + return low + Math.floor(Math.random() * (high - low + 1)); +} + +/** Random float in the interval [low, high). */ +export function randFloat(low: number, high: number): number { + return low + Math.random() * (high - low); +} + +/** Random float in the interval (-range/2, range/2). */ +export function randFloatSpread(range: number): number { + return range * (0.5 - Math.random()); +} + +/** + * Namespace object matching three's `MathUtils` export so ports can keep + * `MathUtils.lerp(...)` call sites unchanged. + */ +export const MathUtils = { + DEG2RAD, + RAD2DEG, + clamp, + lerp, + inverseLerp, + damp, + mapLinear, + euclideanModulo, + pingpong, + smoothstep, + smootherstep, + degToRad, + radToDeg, + randInt, + randFloat, + randFloatSpread, +} as const; diff --git a/playset/math/matrix4.ts b/playset/math/matrix4.ts new file mode 100644 index 0000000..b784035 --- /dev/null +++ b/playset/math/matrix4.ts @@ -0,0 +1,153 @@ +// playset/math/matrix4.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { Vector3 } from "./vector3.ts"; +import type { Quaternion } from "./quaternion.ts"; + +/** + * A 4x4 matrix stored column-major in `elements` (like three.js and OpenGL). + * `set()` takes arguments in row-major order for readability, exactly as + * three does. Mutating methods return `this` so calls chain. + * + * Deliberately lean: only the operations the GameBlocks portable core uses + * (basis construction consumed by `Quaternion#setFromRotationMatrix`) plus + * trivially cheap standard companions. No compose/decompose/invert — the + * inventory shows no usage. + */ +export class Matrix4 { + readonly isMatrix4: true = true; + + elements: number[]; + + constructor() { + // prettier-ignore + this.elements = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; + } + + /** Arguments are row-major; storage is column-major (three convention). */ + set( + n11: number, n12: number, n13: number, n14: number, + n21: number, n22: number, n23: number, n24: number, + n31: number, n32: number, n33: number, n34: number, + n41: number, n42: number, n43: number, n44: number, + ): this { + const te = this.elements; + te[0] = n11; te[4] = n12; te[8] = n13; te[12] = n14; + te[1] = n21; te[5] = n22; te[9] = n23; te[13] = n24; + te[2] = n31; te[6] = n32; te[10] = n33; te[14] = n34; + te[3] = n41; te[7] = n42; te[11] = n43; te[15] = n44; + return this; + } + + identity(): this { + return this.set( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ); + } + + clone(): Matrix4 { + return new Matrix4().copy(this); + } + + copy(m: Matrix4): this { + const te = this.elements; + const me = m.elements; + for (let i = 0; i < 16; i++) te[i] = me[i]!; + return this; + } + + /** Sets the upper-3x3 columns to the given basis axes (rest = identity). */ + makeBasis(xAxis: Vector3, yAxis: Vector3, zAxis: Vector3): this { + return this.set( + xAxis.x, yAxis.x, zAxis.x, 0, + xAxis.y, yAxis.y, zAxis.y, 0, + xAxis.z, yAxis.z, zAxis.z, 0, + 0, 0, 0, 1, + ); + } + + /** Reads the upper-3x3 columns back out into the given vectors. */ + extractBasis(xAxis: Vector3, yAxis: Vector3, zAxis: Vector3): this { + xAxis.setFromMatrixColumn(this, 0); + yAxis.setFromMatrixColumn(this, 1); + zAxis.setFromMatrixColumn(this, 2); + return this; + } + + /** Pure rotation matrix from unit quaternion `q` (translation = 0). */ + makeRotationFromQuaternion(q: Quaternion): this { + const x = q.x, y = q.y, z = q.z, w = q.w; + + const x2 = x + x, y2 = y + y, z2 = z + z; + const xx = x * x2, xy = x * y2, xz = x * z2; + const yy = y * y2, yz = y * z2, zz = z * z2; + const wx = w * x2, wy = w * y2, wz = w * z2; + + return this.set( + 1 - (yy + zz), xy - wz, xz + wy, 0, + xy + wz, 1 - (xx + zz), yz - wx, 0, + xz - wy, yz + wx, 1 - (xx + yy), 0, + 0, 0, 0, 1, + ); + } + + /** + * Rotation-only look-at (three semantics): local -Z ends up pointing from + * `eye` toward `target`, with `up` as the vertical hint. Degenerate inputs + * (eye === target, up parallel to view) are nudged exactly like three. + */ + lookAt(eye: Vector3, target: Vector3, up: Vector3): this { + const te = this.elements; + + _z.subVectors(eye, target); + if (_z.lengthSq() === 0) { + // eye and target are in the same position + _z.z = 1; + } + _z.normalize(); + + _x.crossVectors(up, _z); + if (_x.lengthSq() === 0) { + // up and z are parallel + if (Math.abs(up.z) === 1) { + _z.x += 0.0001; + } else { + _z.z += 0.0001; + } + _z.normalize(); + _x.crossVectors(up, _z); + } + _x.normalize(); + + _y.crossVectors(_z, _x); + + te[0] = _x.x; te[4] = _y.x; te[8] = _z.x; + te[1] = _x.y; te[5] = _y.y; te[9] = _z.y; + te[2] = _x.z; te[6] = _y.z; te[10] = _z.z; + return this; + } + + equals(m: Matrix4): boolean { + const te = this.elements; + const me = m.elements; + for (let i = 0; i < 16; i++) { + if (te[i] !== me[i]) return false; + } + return true; + } +} + +// Module-level scratch temporaries (three's own pattern) — lookAt is +// allocation-free per call. +const _x = /*@__PURE__*/ new Vector3(); +const _y = /*@__PURE__*/ new Vector3(); +const _z = /*@__PURE__*/ new Vector3(); diff --git a/playset/math/quaternion.ts b/playset/math/quaternion.ts new file mode 100644 index 0000000..54f1f48 --- /dev/null +++ b/playset/math/quaternion.ts @@ -0,0 +1,325 @@ +// playset/math/quaternion.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import type { Euler } from "./euler.ts"; +import type { Matrix4 } from "./matrix4.ts"; +import type { Vector3 } from "./vector3.ts"; + +/** + * A quaternion (x, y, z, w) representing a rotation. Mutating methods return + * `this` so calls chain exactly like three.js. + */ +export class Quaternion { + readonly isQuaternion: true = true; + + x: number; + y: number; + z: number; + w: number; + + constructor(x = 0, y = 0, z = 0, w = 1) { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + set(x: number, y: number, z: number, w: number): this { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + return this; + } + + clone(): Quaternion { + return new Quaternion(this.x, this.y, this.z, this.w); + } + + copy(q: Quaternion): this { + this.x = q.x; + this.y = q.y; + this.z = q.z; + this.w = q.w; + return this; + } + + identity(): this { + return this.set(0, 0, 0, 1); + } + + /** Assumes this quaternion has unit length (rotation inverse = conjugate). */ + invert(): this { + return this.conjugate(); + } + + conjugate(): this { + this.x = -this.x; + this.y = -this.y; + this.z = -this.z; + return this; + } + + dot(q: Quaternion): number { + return this.x * q.x + this.y * q.y + this.z * q.z + this.w * q.w; + } + + lengthSq(): number { + return this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w; + } + + length(): number { + return Math.sqrt(this.lengthSq()); + } + + /** Normalizes to unit length; a zero quaternion becomes the identity. */ + normalize(): this { + let l = this.length(); + if (l === 0) { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 1; + } else { + l = 1 / l; + this.x *= l; + this.y *= l; + this.z *= l; + this.w *= l; + } + return this; + } + + /** Sets `this = this * q` (apply q's rotation first, then this). */ + multiply(q: Quaternion): this { + return this.multiplyQuaternions(this, q); + } + + /** Sets `this = q * this` (apply this rotation first, then q). */ + premultiply(q: Quaternion): this { + return this.multiplyQuaternions(q, this); + } + + /** Hamilton product `this = a * b`. */ + multiplyQuaternions(a: Quaternion, b: Quaternion): this { + const ax = a.x, ay = a.y, az = a.z, aw = a.w; + const bx = b.x, by = b.y, bz = b.z, bw = b.w; + + this.x = ax * bw + aw * bx + ay * bz - az * by; + this.y = ay * bw + aw * by + az * bx - ax * bz; + this.z = az * bw + aw * bz + ax * by - ay * bx; + this.w = aw * bw - ax * bx - ay * by - az * bz; + return this; + } + + /** Angle in radians between this rotation and `q`. */ + angleTo(q: Quaternion): number { + return 2 * Math.acos(Math.abs(clamp(this.dot(q), -1, 1))); + } + + /** + * Spherical linear interpolation toward `qb` by `t` (shortest arc). Falls + * back to normalized lerp when the quaternions are nearly parallel. + */ + slerp(qb: Quaternion, t: number): this { + if (t === 0) return this; + if (t === 1) return this.copy(qb); + + const x = this.x, y = this.y, z = this.z, w = this.w; + + let cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z; + if (cosHalfTheta < 0) { + // Take the shortest arc: interpolate toward -qb (same rotation). + this.set(-qb.x, -qb.y, -qb.z, -qb.w); + cosHalfTheta = -cosHalfTheta; + } else { + this.copy(qb); + } + + if (cosHalfTheta >= 1.0) { + // Identical rotations — restore the start value untouched. + return this.set(x, y, z, w); + } + + const sqrSinHalfTheta = 1.0 - cosHalfTheta * cosHalfTheta; + if (sqrSinHalfTheta <= Number.EPSILON) { + // Nearly parallel: normalized linear interpolation is stable here. + const s = 1 - t; + this.x = s * x + t * this.x; + this.y = s * y + t * this.y; + this.z = s * z + t * this.z; + this.w = s * w + t * this.w; + return this.normalize(); + } + + const sinHalfTheta = Math.sqrt(sqrSinHalfTheta); + const halfTheta = Math.atan2(sinHalfTheta, cosHalfTheta); + const ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta; + const ratioB = Math.sin(t * halfTheta) / sinHalfTheta; + + this.x = x * ratioA + this.x * ratioB; + this.y = y * ratioA + this.y * ratioB; + this.z = z * ratioA + this.z * ratioB; + this.w = w * ratioA + this.w * ratioB; + return this; + } + + slerpQuaternions(qa: Quaternion, qb: Quaternion, t: number): this { + return this.copy(qa).slerp(qb, t); + } + + /** `axis` is assumed to be normalized; `angle` is in radians. */ + setFromAxisAngle(axis: Vector3, angle: number): this { + const halfAngle = angle / 2; + const s = Math.sin(halfAngle); + + this.x = axis.x * s; + this.y = axis.y * s; + this.z = axis.z * s; + this.w = Math.cos(halfAngle); + return this; + } + + /** Standard intrinsic Tait-Bryan euler → quaternion for all six orders. */ + setFromEuler(euler: Euler): this { + const c1 = Math.cos(euler.x / 2); + const c2 = Math.cos(euler.y / 2); + const c3 = Math.cos(euler.z / 2); + const s1 = Math.sin(euler.x / 2); + const s2 = Math.sin(euler.y / 2); + const s3 = Math.sin(euler.z / 2); + + switch (euler.order) { + case "XYZ": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "YXZ": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + case "ZXY": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "ZYX": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + case "YZX": + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 + s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + break; + case "XZY": + this.x = s1 * c2 * c3 - c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + break; + } + return this; + } + + /** + * Extracts the rotation from `m`, whose upper-3x3 is assumed to be a pure + * (unscaled) rotation matrix. Trace-based Shepperd method. + */ + setFromRotationMatrix(m: Matrix4): this { + const te = m.elements; + const m11 = te[0]!, m12 = te[4]!, m13 = te[8]!; + const m21 = te[1]!, m22 = te[5]!, m23 = te[9]!; + const m31 = te[2]!, m32 = te[6]!, m33 = te[10]!; + const trace = m11 + m22 + m33; + + if (trace > 0) { + const s = 0.5 / Math.sqrt(trace + 1.0); + this.w = 0.25 / s; + this.x = (m32 - m23) * s; + this.y = (m13 - m31) * s; + this.z = (m21 - m12) * s; + } else if (m11 > m22 && m11 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m11 - m22 - m33); + this.w = (m32 - m23) / s; + this.x = 0.25 * s; + this.y = (m12 + m21) / s; + this.z = (m13 + m31) / s; + } else if (m22 > m33) { + const s = 2.0 * Math.sqrt(1.0 + m22 - m11 - m33); + this.w = (m13 - m31) / s; + this.x = (m12 + m21) / s; + this.y = 0.25 * s; + this.z = (m23 + m32) / s; + } else { + const s = 2.0 * Math.sqrt(1.0 + m33 - m11 - m22); + this.w = (m21 - m12) / s; + this.x = (m13 + m31) / s; + this.y = (m23 + m32) / s; + this.z = 0.25 * s; + } + return this; + } + + /** + * Sets this quaternion to the rotation carrying unit vector `vFrom` onto + * unit vector `vTo`. Antiparallel inputs produce a 180° rotation about an + * axis perpendicular to `vFrom`. + */ + setFromUnitVectors(vFrom: Vector3, vTo: Vector3): this { + let r = vFrom.dot(vTo) + 1; + + if (r < Number.EPSILON) { + // vFrom and vTo point in opposite directions. + r = 0; + if (Math.abs(vFrom.x) > Math.abs(vFrom.z)) { + this.x = -vFrom.y; + this.y = vFrom.x; + this.z = 0; + this.w = r; + } else { + this.x = 0; + this.y = -vFrom.z; + this.z = vFrom.y; + this.w = r; + } + } else { + // cross(vFrom, vTo) with w = 1 + dot, then normalize. + this.x = vFrom.y * vTo.z - vFrom.z * vTo.y; + this.y = vFrom.z * vTo.x - vFrom.x * vTo.z; + this.z = vFrom.x * vTo.y - vFrom.y * vTo.x; + this.w = r; + } + return this.normalize(); + } + + equals(q: Quaternion): boolean { + return q.x === this.x && q.y === this.y && q.z === this.z && q.w === this.w; + } + + fromArray(array: ArrayLike, offset = 0): this { + this.x = array[offset]!; + this.y = array[offset + 1]!; + this.z = array[offset + 2]!; + this.w = array[offset + 3]!; + return this; + } + + toArray(array: number[] = [], offset = 0): number[] { + array[offset] = this.x; + array[offset + 1] = this.y; + array[offset + 2] = this.z; + array[offset + 3] = this.w; + return array; + } +} diff --git a/playset/math/vector3.ts b/playset/math/vector3.ts new file mode 100644 index 0000000..7bdc8d1 --- /dev/null +++ b/playset/math/vector3.ts @@ -0,0 +1,314 @@ +// playset/math/vector3.ts — Three.js-compatible math subset for +// @pocketjs/playset. API mirrors three@0.161 (MIT © three.js authors); +// reimplemented from the standard formulas, not copied. + +import { clamp } from "./math-utils.ts"; +import { Quaternion } from "./quaternion.ts"; +import type { Euler } from "./euler.ts"; +import type { Matrix4 } from "./matrix4.ts"; + +/** + * Structural stand-in for three's BufferAttribute in + * `Vector3#fromBufferAttribute` — anything exposing per-index component + * getters works (including a real three BufferAttribute). + */ +export interface BufferAttributeLike { + getX(index: number): number; + getY(index: number): number; + getZ(index: number): number; +} + +/** + * A 3-component vector with plain `x`/`y`/`z` fields (GameBlocks indexes them + * dynamically as `v[axis]`). Mutating methods return `this` so calls chain + * exactly like three.js. + */ +export class Vector3 { + readonly isVector3: true = true; + + x: number; + y: number; + z: number; + + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + + set(x: number, y: number, z: number): this { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + setScalar(scalar: number): this { + this.x = scalar; + this.y = scalar; + this.z = scalar; + return this; + } + + setX(x: number): this { + this.x = x; + return this; + } + + setY(y: number): this { + this.y = y; + return this; + } + + setZ(z: number): this { + this.z = z; + return this; + } + + clone(): Vector3 { + return new Vector3(this.x, this.y, this.z); + } + + copy(v: Vector3): this { + this.x = v.x; + this.y = v.y; + this.z = v.z; + return this; + } + + add(v: Vector3): this { + this.x += v.x; + this.y += v.y; + this.z += v.z; + return this; + } + + addVectors(a: Vector3, b: Vector3): this { + this.x = a.x + b.x; + this.y = a.y + b.y; + this.z = a.z + b.z; + return this; + } + + addScaledVector(v: Vector3, s: number): this { + this.x += v.x * s; + this.y += v.y * s; + this.z += v.z * s; + return this; + } + + sub(v: Vector3): this { + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + return this; + } + + subVectors(a: Vector3, b: Vector3): this { + this.x = a.x - b.x; + this.y = a.y - b.y; + this.z = a.z - b.z; + return this; + } + + multiply(v: Vector3): this { + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + return this; + } + + multiplyScalar(scalar: number): this { + this.x *= scalar; + this.y *= scalar; + this.z *= scalar; + return this; + } + + divide(v: Vector3): this { + this.x /= v.x; + this.y /= v.y; + this.z /= v.z; + return this; + } + + divideScalar(scalar: number): this { + return this.multiplyScalar(1 / scalar); + } + + negate(): this { + this.x = -this.x; + this.y = -this.y; + this.z = -this.z; + return this; + } + + dot(v: Vector3): number { + return this.x * v.x + this.y * v.y + this.z * v.z; + } + + lengthSq(): number { + return this.x * this.x + this.y * this.y + this.z * this.z; + } + + length(): number { + return Math.sqrt(this.lengthSq()); + } + + /** Normalizes in place; a zero vector stays (0, 0, 0), as in three. */ + normalize(): this { + return this.divideScalar(this.length() || 1); + } + + /** Sets this vector's magnitude to `length` (zero vector stays zero). */ + setLength(length: number): this { + return this.normalize().multiplyScalar(length); + } + + lerp(v: Vector3, alpha: number): this { + this.x += (v.x - this.x) * alpha; + this.y += (v.y - this.y) * alpha; + this.z += (v.z - this.z) * alpha; + return this; + } + + lerpVectors(v1: Vector3, v2: Vector3, alpha: number): this { + this.x = v1.x + (v2.x - v1.x) * alpha; + this.y = v1.y + (v2.y - v1.y) * alpha; + this.z = v1.z + (v2.z - v1.z) * alpha; + return this; + } + + cross(v: Vector3): this { + return this.crossVectors(this, v); + } + + crossVectors(a: Vector3, b: Vector3): this { + const ax = a.x, ay = a.y, az = a.z; + const bx = b.x, by = b.y, bz = b.z; + + this.x = ay * bz - az * by; + this.y = az * bx - ax * bz; + this.z = ax * by - ay * bx; + return this; + } + + /** Projects this vector onto `v` (becomes zero if `v` is zero-length). */ + projectOnVector(v: Vector3): this { + const denominator = v.lengthSq(); + if (denominator === 0) return this.set(0, 0, 0); + const scalar = v.dot(this) / denominator; + return this.copy(v).multiplyScalar(scalar); + } + + /** + * Removes the component of this vector along `planeNormal`, projecting it + * onto the plane through the origin with that normal. A zero-length normal + * leaves the vector unchanged (three's behavior). + */ + projectOnPlane(planeNormal: Vector3): this { + _vector.copy(this).projectOnVector(planeNormal); + return this.sub(_vector); + } + + /** Applies rotation `q` (assumed unit length): v' = q · v · q*. */ + applyQuaternion(q: Quaternion): this { + const vx = this.x, vy = this.y, vz = this.z; + const qx = q.x, qy = q.y, qz = q.z, qw = q.w; + + // t = 2 · cross(q.xyz, v) + const tx = 2 * (qy * vz - qz * vy); + const ty = 2 * (qz * vx - qx * vz); + const tz = 2 * (qx * vy - qy * vx); + + // v' = v + w·t + cross(q.xyz, t) + this.x = vx + qw * tx + qy * tz - qz * ty; + this.y = vy + qw * ty + qz * tx - qx * tz; + this.z = vz + qw * tz + qx * ty - qy * tx; + return this; + } + + /** Rotates about `axis` (assumed normalized) by `angle` radians. */ + applyAxisAngle(axis: Vector3, angle: number): this { + return this.applyQuaternion(_quaternion.setFromAxisAngle(axis, angle)); + } + + applyEuler(euler: Euler): this { + return this.applyQuaternion(_quaternion.setFromEuler(euler)); + } + + /** Applies `m` as a full 4x4 transform, including the perspective divide. */ + applyMatrix4(m: Matrix4): this { + const x = this.x, y = this.y, z = this.z; + const e = m.elements; + + const w = 1 / (e[3]! * x + e[7]! * y + e[11]! * z + e[15]!); + this.x = (e[0]! * x + e[4]! * y + e[8]! * z + e[12]!) * w; + this.y = (e[1]! * x + e[5]! * y + e[9]! * z + e[13]!) * w; + this.z = (e[2]! * x + e[6]! * y + e[10]! * z + e[14]!) * w; + return this; + } + + distanceTo(v: Vector3): number { + return Math.sqrt(this.distanceToSquared(v)); + } + + distanceToSquared(v: Vector3): number { + const dx = this.x - v.x; + const dy = this.y - v.y; + const dz = this.z - v.z; + return dx * dx + dy * dy + dz * dz; + } + + /** Angle to `v` in radians; PI/2 if either vector is zero-length. */ + angleTo(v: Vector3): number { + const denominator = Math.sqrt(this.lengthSq() * v.lengthSq()); + if (denominator === 0) return Math.PI / 2; + const theta = this.dot(v) / denominator; + return Math.acos(clamp(theta, -1, 1)); + } + + /** Copies matrix column `index` (0-3) of `m` into this vector. */ + setFromMatrixColumn(m: Matrix4, index: number): this { + return this.fromArray(m.elements, index * 4); + } + + /** Copies the translation part of `m` into this vector. */ + setFromMatrixPosition(m: Matrix4): this { + const e = m.elements; + this.x = e[12]!; + this.y = e[13]!; + this.z = e[14]!; + return this; + } + + fromBufferAttribute(attribute: BufferAttributeLike, index: number): this { + this.x = attribute.getX(index); + this.y = attribute.getY(index); + this.z = attribute.getZ(index); + return this; + } + + equals(v: Vector3): boolean { + return v.x === this.x && v.y === this.y && v.z === this.z; + } + + fromArray(array: ArrayLike, offset = 0): this { + this.x = array[offset]!; + this.y = array[offset + 1]!; + this.z = array[offset + 2]!; + return this; + } + + toArray(array: number[] = [], offset = 0): number[] { + array[offset] = this.x; + array[offset + 1] = this.y; + array[offset + 2] = this.z; + return array; + } +} + +// Module-level scratch temporaries — mirrors three's own approach so hot +// methods never allocate. +const _vector = /*@__PURE__*/ new Vector3(); +const _quaternion = /*@__PURE__*/ new Quaternion(); diff --git a/playset/modules/math/random-utils.ts b/playset/modules/math/random-utils.ts new file mode 100644 index 0000000..9a1cebc --- /dev/null +++ b/playset/modules/math/random-utils.ts @@ -0,0 +1,54 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/RandomUtils.js. Bit-identical Mulberry32 sequence to the +// original: a playset port and a GameBlocks browser build seeded alike draw +// the SAME stream, which is what makes cross-engine golden traces possible. +// +// Determinism discipline (DETERMINISM.md): never use Math.random in game +// code. Construct one RandomGenerator per subsystem with an explicit seed — +// the shared DEFAULT_PRNG exists for GameBlocks port-compatibility, but two +// subsystems drawing from it interleave their streams (order-dependent). + +export class RandomGenerator { + private state = 0; + + constructor(seed = 42) { + this.seed(seed); + } + + seed(seed = 42): this { + this.state = seed >>> 0; + return this; + } + + /** Mulberry32 — uniform in [0, 1). */ + random(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0; + let t = Math.imul(this.state ^ (this.state >>> 15), 1 | this.state); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + uniform(min: number, max: number): number { + return min + (max - min) * this.random(); + } + + /** Integer in [min, max] (inclusive both ends, like the original). */ + randint(min: number, max: number): number { + return Math.floor(this.uniform(min, max + 1)); + } + + randrange(start: number, stop: number | null = null, step = 1): number { + if (stop == null) { + stop = start; + start = 0; + } + const count = Math.ceil((stop - start) / step); + return start + Math.floor(this.random() * count) * step; + } + + choice(items: readonly T[]): T { + return items[Math.floor(this.random() * items.length)]; + } +} + +export const DEFAULT_PRNG = new RandomGenerator(42); diff --git a/playset/modules/math/scalar-utils.ts b/playset/modules/math/scalar-utils.ts new file mode 100644 index 0000000..a249d2e --- /dev/null +++ b/playset/modules/math/scalar-utils.ts @@ -0,0 +1,47 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/ScalarUtils.js. Verbatim semantics; the smoothing helpers are +// the framerate-independence backbone every motion controller leans on. + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +export function toFinite(value: number, fallback = 0): number { + return Number.isFinite(value) ? value : fallback; +} + +export function lerp(start: number, end: number, alpha: number): number { + return start + (end - start) * alpha; +} + +export function fract(value: number): number { + return value - Math.floor(value); +} + +export function toRad(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +export function toDeg(radians: number): number { + return (radians * 180) / Math.PI; +} + +/** dt-stable exponential smoothing factor: 1 - e^(-dt/lag). */ +export function smoothingAlpha(lag: number, deltaSeconds: number): number { + const safeDelta = Math.max(0, deltaSeconds); + if (lag <= 0) return 1; + return 1 - Math.exp(-safeDelta / lag); +} + +export function smoothToward( + current: number, + target: number, + lag: number, + deltaSeconds: number, +): number { + return current + (target - current) * smoothingAlpha(lag, deltaSeconds); +} diff --git a/playset/modules/math/time-utils.ts b/playset/modules/math/time-utils.ts new file mode 100644 index 0000000..ce55c76 --- /dev/null +++ b/playset/modules/math/time-utils.ts @@ -0,0 +1,60 @@ +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © xt4d) — +// modules/math/TimeUtils.js, with ONE deliberate semantic upgrade: +// +// GameBlocks' Clock falls back to Date.now() unless the caller opts into +// manual mode — the wall clock leaks in by default. In Pocket the wall +// clock is never an input (DETERMINISM.md), so the non-manual path reads +// the VIRTUAL clock (@pocketjs/framework/clock virtualNow): same API, but +// deterministic by default on every host, replayable under host-sim. +// `useManual()` behaves exactly like the original (fixed-step drivers and +// tests advance it by hand). + +import { virtualNow } from "@pocketjs/framework/clock"; + +export interface ClockOptions { + manual?: boolean; + nowMs?: number; +} + +export class Clock { + manual: boolean; + currentMs: number; + + constructor({ manual = false, nowMs = 0 }: ClockOptions = {}) { + this.manual = manual; + this.currentMs = nowMs; + } + + /** Milliseconds — manual value, or VIRTUAL time (never Date.now). */ + now(): number { + return this.manual ? this.currentMs : virtualNow() * 1000; + } + + nowSeconds(): number { + return this.now() * 0.001; + } + + /** GameBlocks name kept; "system" here means the virtual clock. */ + useSystem(): this { + this.manual = false; + return this; + } + + useManual(nowMs: number = this.currentMs): this { + this.manual = true; + this.currentMs = nowMs; + return this; + } + + setMs(nowMs: number): this { + this.currentMs = nowMs; + return this; + } + + advanceMs(deltaMs: number): this { + this.currentMs += deltaMs; + return this; + } +} + +export const DEFAULT_CLOCK = new Clock(); diff --git a/playset/modules/math/vector3-utils.ts b/playset/modules/math/vector3-utils.ts new file mode 100644 index 0000000..e038d0e --- /dev/null +++ b/playset/modules/math/vector3-utils.ts @@ -0,0 +1,55 @@ +// playset/modules/math/vector3-utils.ts — safe Vector3 coercion helpers. +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/math/Vector3Utils.js. Verbatim semantics. + +import { Vector3 } from "../../math/vector3.ts"; +import { DEFAULT_WORLD_BASIS, type VecLike, type WorldBasis } from "./world-basis.ts"; + +export const VECTOR_EPS = 1e-6; + +export function toVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 0, z: 0 }, +): Vector3 { + return new Vector3( + value?.x ?? fallback.x ?? 0, + value?.y ?? fallback.y ?? 0, + value?.z ?? fallback.z ?? 0, + ); +} + +export function toUnitVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 1, z: 0 }, +): Vector3 { + const vector = toVec3(value, fallback); + if (vector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + const fallbackVector = toVec3(fallback); + if (fallbackVector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + return new Vector3(); + } + return fallbackVector.normalize(); + } + return vector.normalize(); +} + +export function toPlanarUnitVec3( + value: VecLike | null | undefined, + fallback: VecLike = { x: 0, y: 0, z: -1 }, + basis: WorldBasis = DEFAULT_WORLD_BASIS, +): Vector3 { + const worldBasis = basis; + const vector = toVec3(value, fallback); + worldBasis.flatten(vector); + if (vector.lengthSq() > VECTOR_EPS * VECTOR_EPS) { + return vector.normalize(); + } + + const fallbackVector = toVec3(fallback); + worldBasis.flatten(fallbackVector); + if (fallbackVector.lengthSq() <= VECTOR_EPS * VECTOR_EPS) { + return new Vector3(); + } + return fallbackVector.normalize(); +} diff --git a/playset/modules/math/world-basis.ts b/playset/modules/math/world-basis.ts new file mode 100644 index 0000000..c03aed7 --- /dev/null +++ b/playset/modules/math/world-basis.ts @@ -0,0 +1,344 @@ +// playset/modules/math/world-basis.ts — the single source of truth for +// gameplay-space coordinates: how right/up/forward map onto world axes, and +// every basis-aware helper (planar math, heading, control signs, frames). +// +// Ported from GameBlocks (github.com/xt4d/GameBlocks, MIT © 2026 Weihao +// Cheng) — modules/math/WorldBasis.js. Verbatim semantics. The default basis +// (right=+X, up=+Y, forward=−Z) matches both Three.js convention and +// pocket3d's camera space, so ported motion math needs no re-derivation. + +import { Matrix4 } from "../../math/matrix4.ts"; +import { Quaternion } from "../../math/quaternion.ts"; +import { Vector3 } from "../../math/vector3.ts"; + +export type AxisName = "x" | "y" | "z"; + +export interface AxisDescriptor { + axis: AxisName; + sign: 1 | -1; +} + +/** Loose axis spec: "+x" / "-z" strings or a descriptor object. */ +export type AxisSpec = string | { axis: string; sign?: number | string }; + +export interface WorldBasisConfig { + right: AxisSpec; + up: AxisSpec; + forward: AxisSpec; +} + +/** Any object with world components — Vector3, poses, plain literals. */ +export interface VecLike { + x?: number; + y?: number; + z?: number; +} + +/** A mutable xyz target (Vector3, or anything shaped like one). */ +export interface MutableVec { + x: number; + y: number; + z: number; +} + +export interface PlanarPair { + right: number; + forward: number; +} + +export interface BasisComponents { + right: number; + up: number; + forward: number; +} + +export interface BasisFrame { + right: Vector3; + up: Vector3; + forward: Vector3; + back: Vector3; +} + +export type ControlDirection = + | "left" + | "right" + | "up" + | "down" + | "forward" + | "backward" + | "counterClockWise" + | "clockWise"; + +const AXES: readonly AxisName[] = ["x", "y", "z"]; +const AXIS_EPS = 1e-9; + +const DEFAULT_AXES: WorldBasisConfig = Object.freeze({ + right: Object.freeze({ axis: "x", sign: 1 }), + up: Object.freeze({ axis: "y", sign: 1 }), + forward: Object.freeze({ axis: "z", sign: -1 }), +}); + +function readSignal(value: boolean | number | null | undefined): number { + if (value === true) return 1; + if (value === false || value == null) return 0; + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function parseAxisDescriptor(value: AxisSpec, label: string): AxisDescriptor { + const raw = + typeof value === "string" + ? value + : value?.axis + ? `${value.sign === -1 || value.sign === "-" ? "-" : "+"}${value.axis}` + : null; + if (typeof raw !== "string") { + throw new Error(`WorldBasis: ${label} must be an axis string like "+x" or "-z"`); + } + + const trimmed = raw.trim().toLowerCase(); + const sign = trimmed.startsWith("-") ? -1 : 1; + const axis = trimmed.replace(/^[+-]/, "") as AxisName; + if (!AXES.includes(axis)) { + throw new Error(`WorldBasis: invalid ${label} axis "${raw}"`); + } + return { axis, sign }; +} + +function validateAxes(right: AxisDescriptor, up: AxisDescriptor, forward: AxisDescriptor): void { + const rawAxes = [right.axis, up.axis, forward.axis]; + if (new Set(rawAxes).size !== 3) { + throw new Error("WorldBasis: right, up, and forward must use three distinct world axes"); + } + + const r = { x: 0, y: 0, z: 0 }; + const f = { x: 0, y: 0, z: 0 }; + r[right.axis] = right.sign; + f[forward.axis] = forward.sign; + const cross = { + x: r.y * f.z - r.z * f.y, + y: r.z * f.x - r.x * f.z, + z: r.x * f.y - r.y * f.x, + }; + if (cross[up.axis] * up.sign <= 0) { + throw new Error("WorldBasis: right x forward must point along up"); + } +} + +function readComponent(value: VecLike | null | undefined, axis: AxisName): number { + return value?.[axis] ?? 0; +} + +export class WorldBasis { + readonly rightAxis: Readonly; + readonly upAxis: Readonly; + readonly forwardAxis: Readonly; + /** Multiply a positive control delta by these to get signed movement or a + * right-hand-rule rotation angle. */ + readonly controlSigns: Readonly>; + + constructor(config: WorldBasisConfig = DEFAULT_AXES) { + const right = parseAxisDescriptor(config.right, "right"); + const up = parseAxisDescriptor(config.up, "up"); + const forward = parseAxisDescriptor(config.forward, "forward"); + + validateAxes(right, up, forward); + + this.rightAxis = Object.freeze(right); + this.upAxis = Object.freeze(up); + this.forwardAxis = Object.freeze(forward); + + this.controlSigns = Object.freeze({ + left: -1, + right: 1, + up: 1, + down: -1, + forward: 1, + backward: -1, + counterClockWise: 1, + clockWise: -1, + }); + } + + rightVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.rightAxis.axis] = this.rightAxis.sign; + return target; + } + + upVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.upAxis.axis] = this.upAxis.sign; + return target; + } + + downVector(target: Vector3 = new Vector3()): Vector3 { + return this.upVector(target).multiplyScalar(-1); + } + + forwardVector(target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.forwardAxis.axis] = this.forwardAxis.sign; + return target; + } + + rightComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.rightAxis.axis) * this.rightAxis.sign; + } + + upComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.upAxis.axis) * this.upAxis.sign; + } + + forwardComponent(value: VecLike | null | undefined): number { + return readComponent(value, this.forwardAxis.axis) * this.forwardAxis.sign; + } + + setHeight(target: T, height = 0): T { + target[this.upAxis.axis] = this.upAxis.sign * height; + return target; + } + + flatten(target: T): T { + return this.setHeight(target, 0); + } + + addHeight(target: T, delta = 0): T { + target[this.upAxis.axis] = + readComponent(target, this.upAxis.axis) + this.upAxis.sign * delta; + return target; + } + + hasWorldPlanarComponents(value: VecLike | null | undefined): boolean { + return ( + Boolean(value) && + Number.isFinite(value?.[this.rightAxis.axis]) && + Number.isFinite(value?.[this.forwardAxis.axis]) + ); + } + + toPlanar(value: VecLike | null | undefined, out: PlanarPair = { right: 0, forward: 0 }): PlanarPair { + out.right = this.rightComponent(value); + out.forward = this.forwardComponent(value); + return out; + } + + planarDelta( + to: VecLike | null | undefined, + from: VecLike | null | undefined, + out: PlanarPair = { right: 0, forward: 0 }, + ): PlanarPair { + out.right = this.rightComponent(to) - this.rightComponent(from); + out.forward = this.forwardComponent(to) - this.forwardComponent(from); + return out; + } + + fromBasisComponents(right = 0, up = 0, forward = 0, target: Vector3 = new Vector3()): Vector3 { + target.set(0, 0, 0); + target[this.rightAxis.axis] = this.rightAxis.sign * right; + target[this.upAxis.axis] = this.upAxis.sign * up; + target[this.forwardAxis.axis] = this.forwardAxis.sign * forward; + return target; + } + + toBasisComponents( + value: VecLike | null | undefined, + out: BasisComponents = { right: 0, up: 0, forward: 0 }, + ): BasisComponents { + out.right = this.rightComponent(value); + out.up = this.upComponent(value); + out.forward = this.forwardComponent(value); + return out; + } + + controlSignal(direction: ControlDirection, signal: boolean | number | null | undefined): number { + if (Object.prototype.hasOwnProperty.call(this.controlSigns, direction)) { + return this.controlSigns[direction] * readSignal(signal); + } + throw new Error(`WorldBasis: unknown control direction "${direction as string}"`); + } + + surfaceNormalFromSlopes(rightSlope = 0, forwardSlope = 0, target: Vector3 = new Vector3()): Vector3 { + // For P(r, f) = r*right + h(r,f)*up + f*forward, an up-facing normal is + // P_f x P_r = up - h_r*right - h_f*forward. + return this.fromBasisComponents(-rightSlope, 1, -forwardSlope, target).normalize(); + } + + // Angles are radians. Using the right-hand rule, positive rotation is + // counter-clockwise when looking from the positive end of the rotation axis + // toward the origin. + // yaw is positive CCW from the +up side; + // pitch is positive CCW from the +right side; + // roll is positive CCW from the +forward side. + yawPitchRollFrame(yaw = 0, pitch = 0, roll = 0): BasisFrame { + const pitchCos = Math.cos(pitch); + const forward = this.fromBasisComponents( + -Math.sin(yaw) * pitchCos, + Math.sin(pitch), + Math.cos(yaw) * pitchCos, + ).normalize(); + const right = this.fromBasisComponents(Math.cos(yaw), 0, Math.sin(yaw)).normalize(); + const up = new Vector3().crossVectors(right, forward).normalize(); + + if (roll) { + right.applyAxisAngle(forward, roll).normalize(); + up.applyAxisAngle(forward, roll).normalize(); + } + + return { + right, + up, + forward, + back: forward.clone().multiplyScalar(-1), + }; + } + + distanceSqPlanar(a: VecLike | null | undefined, b: VecLike | null | undefined): number { + const dRight = this.rightComponent(a) - this.rightComponent(b); + const dForward = this.forwardComponent(a) - this.forwardComponent(b); + return dRight * dRight + dForward * dForward; + } + + planarLength(value: VecLike | null | undefined): number { + const right = this.rightComponent(value); + const forward = this.forwardComponent(value); + return Math.sqrt(right * right + forward * forward); + } + + sideVector(value: VecLike | null | undefined, preferredDirection = 1, target: Vector3 = new Vector3()): Vector3 { + const right = this.rightComponent(value); + const forward = this.forwardComponent(value); + return this.fromBasisComponents(forward * preferredDirection, 0, -right * preferredDirection, target); + } + + threeObjectCanonicalToBasisQuaternion(target: Quaternion = new Quaternion()): Quaternion { + // Upright mesh canonical: +X <-> right, +Y <-> up, -Z <-> forward + return target.setFromRotationMatrix( + new Matrix4().makeBasis( + this.rightVector(), + this.upVector(), + this.forwardVector().multiplyScalar(-1), + ), + ); + } + + threePlaneCanonicalToBasisQuaternion(target: Quaternion = new Quaternion()): Quaternion { + // PlaneGeometry canonical: +X <-> right, +Y <-> forward, +Z <-> up + return target.setFromRotationMatrix( + new Matrix4().makeBasis(this.rightVector(), this.forwardVector(), this.upVector()), + ); + } + + forwardToYaw(forward: VecLike | null | undefined): number { + const right = this.rightComponent(forward); + const forwardComponent = this.forwardComponent(forward); + if (right * right + forwardComponent * forwardComponent <= AXIS_EPS) return 0; + return Math.atan2(-right, forwardComponent); + } +} + +export const DEFAULT_WORLD_BASIS: WorldBasis = Object.freeze(new WorldBasis(DEFAULT_AXES)); + +export function createWorldBasis(config: WorldBasisConfig = DEFAULT_AXES): WorldBasis { + return new WorldBasis(config); +} diff --git a/playset/modules/physics/collision-world.ts b/playset/modules/physics/collision-world.ts new file mode 100644 index 0000000..f0e2bb5 --- /dev/null +++ b/playset/modules/physics/collision-world.ts @@ -0,0 +1,492 @@ +// playset/modules/physics/collision-world.ts — the deterministic collision +// core. Playset-native (no GameBlocks counterpart): it replaces the injected +// Rapier `world` in the ported environments and batch resolvers. +// +// Scope is deliberately v1 (what the GameBlocks demo class actually needs): +// static cuboid / y-cylinder / ball colliders, a terrain heightfield sampler +// as the ground authority, planar capsule push-out with wall sliding and +// ground snapping, and raycasts for aiming. Dynamic rigid bodies, wedge +// trimeshes and stacked-shape climbing are the native Rust block follow-up; +// resolution here is planar (walls block, walkable tops carry you), which +// covers arenas, tracks and natural terrain worlds faithfully. +// +// Everything is deterministic: colliders resolve in insertion order, all +// math is plain f64, no wall clock, no Math.random. + +import { Vector3 } from "../../math/vector3.ts"; +import { Quaternion } from "../../math/quaternion.ts"; +import { DEFAULT_WORLD_BASIS, type WorldBasis, type VecLike } from "../math/world-basis.ts"; +import { clamp } from "../math/scalar-utils.ts"; + +/** Anything with a heightAt — every terrain sampler qualifies. */ +export interface TerrainLike { + heightAt(right: number, forward: number): number; +} + +export interface ColliderCommon { + /** Walls block planar motion; non-solid shapes only answer raycasts. */ + solid?: boolean; + /** Walkable shapes contribute their top face to groundHeightAt. */ + walkable?: boolean; + /** Opaque owner tag returned by queries (entity lookup). */ + tag?: unknown; +} + +export interface CuboidDesc extends ColliderCommon { + position: VecLike; + /** Yaw-only orientation is the common case; full quaternions accepted. */ + quaternion?: { x: number; y: number; z: number; w: number }; + halfExtents: VecLike; +} + +export interface CylinderDesc extends ColliderCommon { + position: VecLike; + halfHeight: number; + radius: number; +} + +export interface BallDesc extends ColliderCommon { + position: VecLike; + radius: number; +} + +export type ColliderHandle = number; + +export interface CapsuleOptions { + radius: number; + /** Capsule half height (feet at position - halfHeight along up). */ + halfHeight: number; + /** Max ground-height rise the mover steps onto without being blocked. */ + climb?: number; + /** Snap-down distance when airborne-but-near-ground (0 disables). */ + snap?: number; +} + +export interface CapsuleResult { + position: Vector3; + grounded: boolean; + /** True when a solid collider clipped the planar motion this resolve. */ + hitWall: boolean; +} + +export interface RaycastHit { + distance: number; + point: Vector3; + handle: ColliderHandle; + tag: unknown; +} + +interface Shape { + handle: ColliderHandle; + kind: "cuboid" | "cylinder" | "ball"; + solid: boolean; + walkable: boolean; + tag: unknown; + // basis-space cache (right/up/forward components) + right: number; + up: number; + forward: number; + // cuboid + hRight: number; + hUp: number; + hForward: number; + yaw: number; // planar rotation about up + // cylinder/ball + radius: number; + halfHeight: number; +} + +const EPS = 1e-9; + +export class CollisionWorld { + readonly basis: WorldBasis; + private terrain: TerrainLike | null = null; + private shapes: Shape[] = []; + private nextHandle = 1; + + constructor({ basis = DEFAULT_WORLD_BASIS }: { basis?: WorldBasis } = {}) { + this.basis = basis; + } + + setTerrain(sampler: TerrainLike | null): void { + this.terrain = sampler; + } + + addCuboid(desc: CuboidDesc): ColliderHandle { + const s = this.baseShape("cuboid", desc); + s.hRight = Math.abs(this.basis.rightComponent(desc.halfExtents)); + s.hUp = Math.abs(this.basis.upComponent(desc.halfExtents)); + s.hForward = Math.abs(this.basis.forwardComponent(desc.halfExtents)); + s.yaw = desc.quaternion ? quatToPlanarYaw(desc.quaternion, this.basis) : 0; + this.shapes.push(s); + return s.handle; + } + + addCylinder(desc: CylinderDesc): ColliderHandle { + const s = this.baseShape("cylinder", desc); + s.radius = desc.radius; + s.halfHeight = desc.halfHeight; + this.shapes.push(s); + return s.handle; + } + + addBall(desc: BallDesc): ColliderHandle { + const s = this.baseShape("ball", desc); + s.radius = desc.radius; + s.halfHeight = desc.radius; + this.shapes.push(s); + return s.handle; + } + + remove(handle: ColliderHandle): void { + const i = this.shapes.findIndex((s) => s.handle === handle); + if (i >= 0) this.shapes.splice(i, 1); + } + + clear(): void { + this.shapes.length = 0; + } + + get colliderCount(): number { + return this.shapes.length; + } + + /** Ground authority: terrain height plus any walkable top underfoot. */ + groundHeightAt(right: number, forward: number): number { + let h = this.terrain ? this.terrain.heightAt(right, forward) : 0; + for (const s of this.shapes) { + if (!s.walkable) continue; + if (!this.planarInside(s, right, forward, 0)) continue; + const top = s.up + (s.kind === "cuboid" ? s.hUp : s.halfHeight); + if (top > h) h = top; + } + return h; + } + + /** + * Move a capsule from `current` toward `desired`: solid shapes push the + * planar motion out (slide, insertion order), then the mover grounds on + * groundHeightAt with `climb` step-up and `snap` snap-down semantics. + * `current` is not mutated; the result vector is freshly allocated. + */ + resolveCapsule(current: Vector3, desired: Vector3, opts: CapsuleOptions): CapsuleResult { + const { radius, halfHeight } = opts; + const climb = opts.climb ?? 0.55; + const snap = opts.snap ?? 0.3; + const b = this.basis; + + let right = b.rightComponent(desired); + let forward = b.forwardComponent(desired); + let up = b.upComponent(desired); + const feetOffset = halfHeight; + let hitWall = false; + + // Planar push-out vs solids whose vertical span overlaps the capsule. + for (const s of this.shapes) { + if (!s.solid) continue; + const feet = up - feetOffset; + const head = up + feetOffset; + const sBottom = s.up - (s.kind === "cuboid" ? s.hUp : s.halfHeight); + const sTop = s.up + (s.kind === "cuboid" ? s.hUp : s.halfHeight); + if (head <= sBottom + EPS || feet >= sTop - EPS) continue; + // Walkable shapes we can step onto don't wall us when the rise fits. + if (s.walkable && sTop - feet <= climb) continue; + + if (s.kind === "cuboid") { + const pushed = pushOutOfBox(s, right, forward, radius); + if (pushed) { + right = pushed.right; + forward = pushed.forward; + hitWall = true; + } + } else { + const dr = right - s.right; + const df = forward - s.forward; + const minDist = s.radius + radius; + const distSq = dr * dr + df * df; + if (distSq < minDist * minDist) { + const dist = Math.sqrt(distSq); + const nr = dist > EPS ? dr / dist : 1; + const nf = dist > EPS ? df / dist : 0; + right = s.right + nr * minDist; + forward = s.forward + nf * minDist; + hitWall = true; + } + } + } + + // Ground pass. + const ground = this.groundHeightAt(right, forward); + const feet = up - feetOffset; + let grounded = false; + if (feet <= ground + EPS) { + up = ground + feetOffset; + grounded = true; + } else if (snap > 0 && feet - ground <= snap) { + up = ground + feetOffset; + grounded = true; + } + + const position = b.fromBasisComponents(right, up, forward); + return { position, grounded, hitWall }; + } + + /** + * Nearest hit along a ray (solid and non-solid shapes both report; terrain + * is ray-marched at fixed 0.5-unit steps then bisected — deterministic). + */ + raycast(origin: Vector3, direction: Vector3, maxDistance: number): RaycastHit | null { + const b = this.basis; + const o = { r: b.rightComponent(origin), u: b.upComponent(origin), f: b.forwardComponent(origin) }; + const dir = b.toBasisComponents(direction); + const dLen = Math.sqrt(dir.right ** 2 + dir.up ** 2 + dir.forward ** 2); + if (dLen <= EPS || !(maxDistance > 0)) return null; + const d = { r: dir.right / dLen, u: dir.up / dLen, f: dir.forward / dLen }; + + let best: { t: number; s: Shape | null } | null = null; + + for (const s of this.shapes) { + const t = + s.kind === "cuboid" + ? rayVsBox(o, d, s, maxDistance) + : s.kind === "ball" + ? rayVsSphere(o, d, s.right, s.up, s.forward, s.radius, maxDistance) + : rayVsCylinder(o, d, s, maxDistance); + if (t !== null && (best === null || t < best.t)) best = { t, s }; + } + + // Terrain march. + if (this.terrain) { + const limit = best ? best.t : maxDistance; + const t = rayVsTerrain(o, d, this.terrain, limit); + if (t !== null && (best === null || t < best.t)) best = { t, s: null }; + } + + if (!best) return null; + const point = b.fromBasisComponents(o.r + d.r * best.t, o.u + d.u * best.t, o.f + d.f * best.t); + return { + distance: best.t, + point, + handle: best.s ? best.s.handle : 0, + tag: best.s ? best.s.tag : null, + }; + } + + private baseShape(kind: Shape["kind"], desc: ColliderCommon & { position: VecLike }): Shape { + return { + handle: this.nextHandle++, + kind, + solid: desc.solid ?? true, + walkable: desc.walkable ?? false, + tag: desc.tag ?? null, + right: this.basis.rightComponent(desc.position), + up: this.basis.upComponent(desc.position), + forward: this.basis.forwardComponent(desc.position), + hRight: 0, + hUp: 0, + hForward: 0, + yaw: 0, + radius: 0, + halfHeight: 0, + }; + } + + private planarInside(s: Shape, right: number, forward: number, inflate: number): boolean { + if (s.kind === "cuboid") { + const { lr, lf } = toBoxLocal(s, right, forward); + return Math.abs(lr) <= s.hRight + inflate && Math.abs(lf) <= s.hForward + inflate; + } + const dr = right - s.right; + const df = forward - s.forward; + const rad = s.radius + inflate; + return dr * dr + df * df <= rad * rad; + } +} + +// --------------------------------------------------------------------------- +// Shape math (planar, basis space) +// --------------------------------------------------------------------------- + +function quatToPlanarYaw( + q: { x: number; y: number; z: number; w: number }, + basis: WorldBasis, +): number { + // Rotate the forward axis, read its planar heading. Yaw-only in v1: any + // tilt component is dropped (environments only yaw their wall boxes). + const f = basis.forwardVector(); + const v = new Vector3(f.x, f.y, f.z).applyQuaternion( + new Quaternion(q.x, q.y, q.z, q.w), + ); + return basis.forwardToYaw(v); +} + +function toBoxLocal(s: Shape, right: number, forward: number): { lr: number; lf: number } { + const dr = right - s.right; + const df = forward - s.forward; + const c = Math.cos(-s.yaw); + const sn = Math.sin(-s.yaw); + // planar rotation: +yaw turns forward toward -right (right-hand rule, up axis) + return { lr: c * dr + sn * df, lf: -sn * dr + c * df }; +} + +function fromBoxLocal(s: Shape, lr: number, lf: number): { right: number; forward: number } { + const c = Math.cos(s.yaw); + const sn = Math.sin(s.yaw); + return { right: s.right + c * lr + sn * lf, forward: s.forward + -sn * lr + c * lf }; +} + +function pushOutOfBox( + s: Shape, + right: number, + forward: number, + radius: number, +): { right: number; forward: number } | null { + const { lr, lf } = toBoxLocal(s, right, forward); + // Closest point on the box in local planar space. + const cr = clamp(lr, -s.hRight, s.hRight); + const cf = clamp(lf, -s.hForward, s.hForward); + const dr = lr - cr; + const df = lf - cf; + const distSq = dr * dr + df * df; + if (distSq >= radius * radius) return null; + let outLr: number; + let outLf: number; + if (distSq > EPS * EPS) { + // Outside the box face: push along the separation direction. + const dist = Math.sqrt(distSq); + outLr = cr + (dr / dist) * radius; + outLf = cf + (df / dist) * radius; + } else { + // Center inside the box: exit through the nearest face. + const exitR = s.hRight + radius - Math.abs(lr); + const exitF = s.hForward + radius - Math.abs(lf); + if (exitR <= exitF) { + outLr = (lr >= 0 ? 1 : -1) * (s.hRight + radius); + outLf = lf; + } else { + outLr = lr; + outLf = (lf >= 0 ? 1 : -1) * (s.hForward + radius); + } + } + return fromBoxLocal(s, outLr, outLf); +} + +interface BasisPoint { + r: number; + u: number; + f: number; +} + +function rayVsSphere( + o: BasisPoint, + d: BasisPoint, + cr: number, + cu: number, + cf: number, + radius: number, + maxT: number, +): number | null { + const or = o.r - cr; + const ou = o.u - cu; + const of_ = o.f - cf; + const b = or * d.r + ou * d.u + of_ * d.f; + const c = or * or + ou * ou + of_ * of_ - radius * radius; + const disc = b * b - c; + if (disc < 0) return null; + const t = -b - Math.sqrt(disc); + return t >= 0 && t <= maxT ? t : null; +} + +function rayVsCylinder(o: BasisPoint, d: BasisPoint, s: Shape, maxT: number): number | null { + // Infinite y-cylinder intersection clipped to the shape's vertical span. + const or = o.r - s.right; + const of_ = o.f - s.forward; + const a = d.r * d.r + d.f * d.f; + let tSide: number | null = null; + if (a > EPS) { + const b = or * d.r + of_ * d.f; + const c = or * or + of_ * of_ - s.radius * s.radius; + const disc = b * b - a * c; + if (disc >= 0) { + const t = (-b - Math.sqrt(disc)) / a; + if (t >= 0 && t <= maxT) { + const u = o.u + d.u * t; + if (Math.abs(u - s.up) <= s.halfHeight) tSide = t; + } + } + } + // Caps. + let tCap: number | null = null; + if (Math.abs(d.u) > EPS) { + for (const capU of [s.up + s.halfHeight, s.up - s.halfHeight]) { + const t = (capU - o.u) / d.u; + if (t < 0 || t > maxT) continue; + const rr = o.r + d.r * t - s.right; + const ff = o.f + d.f * t - s.forward; + if (rr * rr + ff * ff <= s.radius * s.radius) { + if (tCap === null || t < tCap) tCap = t; + } + } + } + if (tSide === null) return tCap; + if (tCap === null) return tSide; + return Math.min(tSide, tCap); +} + +function rayVsBox(o: BasisPoint, d: BasisPoint, s: Shape, maxT: number): number | null { + // Rotate the ray into box-local planar space (up axis unchanged), then slab. + const c = Math.cos(-s.yaw); + const sn = Math.sin(-s.yaw); + const olr = c * (o.r - s.right) + sn * (o.f - s.forward); + const olf = -sn * (o.r - s.right) + c * (o.f - s.forward); + const dlr = c * d.r + sn * d.f; + const dlf = -sn * d.r + c * d.f; + const ou = o.u - s.up; + + let tMin = 0; + let tMax = maxT; + for (const [oc, dc, h] of [ + [olr, dlr, s.hRight], + [ou, d.u, s.hUp], + [olf, dlf, s.hForward], + ] as const) { + if (Math.abs(dc) < EPS) { + if (Math.abs(oc) > h) return null; + continue; + } + let t1 = (-h - oc) / dc; + let t2 = (h - oc) / dc; + if (t1 > t2) [t1, t2] = [t2, t1]; + tMin = Math.max(tMin, t1); + tMax = Math.min(tMax, t2); + if (tMin > tMax) return null; + } + return tMin; +} + +function rayVsTerrain(o: BasisPoint, d: BasisPoint, terrain: TerrainLike, maxT: number): number | null { + const STEP = 0.5; + let prevT = 0; + let prevAbove = o.u - terrain.heightAt(o.r, o.f); + if (prevAbove <= 0) return 0; + for (let t = STEP; t <= maxT + EPS; t += STEP) { + const tt = Math.min(t, maxT); + const above = o.u + d.u * tt - terrain.heightAt(o.r + d.r * tt, o.f + d.f * tt); + if (above <= 0) { + // Bisect the crossing for a stable hit point. + let lo = prevT; + let hi = tt; + for (let i = 0; i < 16; i++) { + const mid = (lo + hi) / 2; + const a = o.u + d.u * mid - terrain.heightAt(o.r + d.r * mid, o.f + d.f * mid); + if (a > 0) lo = mid; + else hi = mid; + } + return hi; + } + prevT = tt; + prevAbove = above; + if (tt >= maxT) break; + } + return null; +} diff --git a/playset/scene3d/client.ts b/playset/scene3d/client.ts new file mode 100644 index 0000000..7ca9309 --- /dev/null +++ b/playset/scene3d/client.ts @@ -0,0 +1,380 @@ +// playset/scene3d/client.ts — the guest-side retained mirror over Scene3dOps. +// +// Port affordance: a SceneNode carries mutable `position` / `quaternion` / +// `scale` value objects (playset/math, three-compatible), so GameBlocks-style +// model controllers port verbatim: `node.position.copy(p)`, +// `node.quaternion.setFromRotationMatrix(m)`. Nothing crosses the FFI when +// you mutate — `scene.flush()` once per frame diffs every node against its +// last-sent pose and issues ONE batched writePoses op (Law 1: state lives +// guest-side, writes are batched intent). +// +// Degradation: constructed with no ops (host has no 3D), the whole client is +// a pure-state mirror — factories, controllers and flush() all run, nothing +// is emitted. Games stay headless-testable on hosts without a 3D core. + +import { Vector3 } from "../math/vector3.ts"; +import { Quaternion } from "../math/quaternion.ts"; +import { Matrix4 } from "../math/matrix4.ts"; +import { + detectScene3d, + MAT, + POSE_STRIDE, + SPRITE_STRIDE, + BEAM_STRIDE, + type Scene3dOps, +} from "./ops.ts"; + +export { MAT } from "./ops.ts"; + +const NO_TINT = 0xffffffff; + +/** A node in the retained transform hierarchy (three Object3D/Group analog). */ +export class SceneNode { + readonly position = new Vector3(); + readonly quaternion = new Quaternion(); + readonly scale = new Vector3(1, 1, 1); + + /** @internal */ readonly __id: number; + /** @internal */ __last: Float64Array | null = null; // 10 floats; null = never sent + private __visible = true; + private __dead = false; + + constructor( + private readonly scene: Scene3D, + id: number, + public parent: SceneNode | null, + ) { + this.__id = id; + } + + get visible(): boolean { + return this.__visible; + } + + set visible(on: boolean) { + if (this.__visible === on) return; + this.__visible = on; + this.scene.__ops?.nodeSetVisible(this.__id, on ? 1 : 0); + } + + /** Reparent `child` under this node (three `group.add(child)` shape). */ + add(child: SceneNode): this { + if (child.parent === this) return this; + child.parent = this; + this.scene.__ops?.nodeSetParent(child.__id, this.__id); + return this; + } + + /** Attach geometry+material (handles from Scene3D geometry/material helpers). */ + setMesh(geomId: number, matId: number): this { + this.scene.__ops?.meshSet(this.__id, geomId, matId); + return this; + } + + /** Per-instance ABGR tint; call with no args to clear. */ + setTint(color: number = NO_TINT): this { + this.scene.__ops?.nodeSetTint(this.__id, color >>> 0); + return this; + } + + /** Destroy this node and its subtree (mirrors are dropped on flush). */ + destroy(): void { + if (this.__dead) return; + this.__dead = true; + this.scene.__removeNode(this); + this.scene.__ops?.nodeDestroy(this.__id); + } + + /** @internal */ + get __alive(): boolean { + return !this.__dead; + } +} + +/** The scene camera — a pose plus lens; flushed with the node batch. */ +export class Camera3D { + readonly position = new Vector3(0, 0, 10); + readonly quaternion = new Quaternion(); + fovY = (60 * Math.PI) / 180; + znear = 0.1; + zfar = 1000; + /** Viewport aspect for guest-side ray math (PSP screen by default). The + * host renders with the bound node's real rect; this only feeds + * unprojection helpers like rayFromNdc. */ + aspect = 480 / 272; + /** @internal */ __last: Float64Array | null = null; + + private static readonly _m = new Matrix4(); + + /** Aim the camera at a world point (+Y-up look-at, three semantics). */ + lookAt(target: Vector3): this { + Camera3D._m.lookAt(this.position, target, Camera3D._up); + this.quaternion.setFromRotationMatrix(Camera3D._m); + return this; + } + + /** World-space ray direction through an NDC point (crosshair picking — + * the guest-side replacement for Raycaster.setFromCamera). */ + rayFromNdc(ndcX: number, ndcY: number, target = new Vector3()): Vector3 { + const halfTan = Math.tan(this.fovY / 2); + return target + .set(ndcX * halfTan * this.aspect, ndcY * halfTan, -1) + .applyQuaternion(this.quaternion) + .normalize(); + } + + private static readonly _up = new Vector3(0, 1, 0); +} + +/** A fixed-capacity billboard pool. Refill `pos`/`colors` then set `count`. */ +export class SpritePool { + readonly buf: Float32Array; + readonly colors: Uint32Array; + count = 0; + + constructor( + private readonly scene: Scene3D, + /** @internal */ readonly __id: number, + readonly capacity: number, + stride: number, + ) { + this.buf = new Float32Array(capacity * stride); + this.colors = new Uint32Array(capacity); + } + + /** @internal */ + __flush(): void { + const ops = this.scene.__ops; + if (!ops) return; + if (this.__id < 0) return; + // Pools are replace-per-frame by contract; skip only when empty twice. + if (this.count === 0 && this.__wasEmpty) return; + this.__wasEmpty = this.count === 0; + if (this instanceof BeamPool) ops.writeBeams(this.__id, this.buf, this.colors, this.count); + else ops.writeSprites(this.__id, this.buf, this.colors, this.count); + } + + private __wasEmpty = false; +} + +export class BeamPool extends SpritePool {} + +/** + * The retained scene root. One Scene3D per . All handle caches + * (geometry, material) are per-scene-client but host handles are global — + * two Scene3D instances sharing one host share dedup'd geoms transparently. + */ +export class Scene3D { + /** @internal — null when the host has no 3D (pure-mirror mode). */ + readonly __ops: Scene3dOps | null; + /** @internal */ readonly __scene: number; + readonly camera = new Camera3D(); + + private readonly nodes: SceneNode[] = []; + private readonly matCache = new Map(); + private readonly geomCache = new Map(); + private readonly pools: SpritePool[] = []; + private poseBuf = new Float32Array(64 * POSE_STRIDE); + private nextLocalId = 1; // pure-mirror mode id spring + + constructor(injected?: Scene3dOps | null) { + this.__ops = injected === undefined ? detectScene3d() : injected; + this.__scene = this.__ops ? this.__ops.sceneCreate() : 0; + } + + // -- nodes ----------------------------------------------------------------- + + node(parent?: SceneNode): SceneNode { + const pid = parent ? parent.__id : 0; + const id = this.__ops ? this.__ops.nodeCreate(this.__scene, pid) : this.nextLocalId++; + const n = new SceneNode(this, id, parent ?? null); + this.nodes.push(n); + return n; + } + + /** Node with a mesh attached — the `new Mesh(geo, mat)` + `add` shorthand. */ + mesh(geomId: number, matId: number, parent?: SceneNode): SceneNode { + return this.node(parent).setMesh(geomId, matId); + } + + /** @internal */ + __removeNode(n: SceneNode): void { + const i = this.nodes.indexOf(n); + if (i >= 0) this.nodes.splice(i, 1); + } + + // -- geometry (cached by params — factories can re-request freely) ---------- + + box(hx: number, hy: number, hz: number): number { + return this.geom(`b|${hx}|${hy}|${hz}`, (o) => o.geomBox(hx, hy, hz)); + } + sphere(radius: number, segments = 12): number { + return this.geom(`s|${radius}|${segments}`, (o) => o.geomSphere(radius, segments)); + } + cylinder(rTop: number, rBottom: number, height: number, segments = 12): number { + return this.geom(`c|${rTop}|${rBottom}|${height}|${segments}`, (o) => + o.geomCylinder(rTop, rBottom, height, segments), + ); + } + cone(radius: number, height: number, segments = 12): number { + return this.geom(`k|${radius}|${height}|${segments}`, (o) => o.geomCone(radius, height, segments)); + } + plane(w: number, d: number): number { + return this.geom(`p|${w}|${d}`, (o) => o.geomPlane(w, d)); + } + torus(radius: number, tube: number, segments = 16, tubeSegments = 8): number { + return this.geom(`t|${radius}|${tube}|${segments}|${tubeSegments}`, (o) => + o.geomTorus(radius, tube, segments, tubeSegments), + ); + } + /** Uncached (buffers are unique by construction). */ + meshGeom(positions: Float32Array, indices: Uint32Array, colors: Float32Array | null): number { + return this.__ops ? this.__ops.geomMesh(positions, indices, colors) : this.nextLocalId++; + } + heightfield( + w: number, + d: number, + cols: number, + rows: number, + heights: Float32Array, + colors: Float32Array | null, + ): number { + return this.__ops + ? this.__ops.geomHeightfield(w, d, cols, rows, heights, colors) + : this.nextLocalId++; + } + + private geom(key: string, make: (o: Scene3dOps) => number): number { + const hit = this.geomCache.get(key); + if (hit !== undefined) return hit; + const id = this.__ops ? make(this.__ops) : this.nextLocalId++; + this.geomCache.set(key, id); + return id; + } + + // -- materials --------------------------------------------------------------- + + material(color: number, flags = 0): number { + const key = `${color >>> 0}|${flags}`; + const hit = this.matCache.get(key); + if (hit !== undefined) return hit; + const id = this.__ops ? this.__ops.material(color >>> 0, flags) : this.nextLocalId++; + this.matCache.set(key, id); + return id; + } + + // -- environment ---------------------------------------------------------------- + + sun(dir: Vector3, color: number): void { + this.__ops?.sun(this.__scene, dir.x, dir.y, dir.z, color >>> 0); + } + ambient(skyColor: number, groundColor: number): void { + this.__ops?.ambient(this.__scene, skyColor >>> 0, groundColor >>> 0); + } + fog(color: number, near: number, far: number): void { + this.__ops?.fog(this.__scene, color >>> 0, near, far); + } + sky(zenithColor: number, horizonColor: number): void { + this.__ops?.sky(this.__scene, zenithColor >>> 0, horizonColor >>> 0); + } + + // -- pools -------------------------------------------------------------------------- + + spritePool(capacity: number, matId: number): SpritePool { + const id = this.__ops ? this.__ops.spritePool(this.__scene, capacity, matId) : -1; + const p = new SpritePool(this, id, capacity, SPRITE_STRIDE); + this.pools.push(p); + return p; + } + beamPool(capacity: number, matId: number): BeamPool { + const id = this.__ops ? this.__ops.beamPool(this.__scene, capacity, matId) : -1; + const p = new BeamPool(this, id, capacity, BEAM_STRIDE); + this.pools.push(p); + return p; + } + + /** An additive unlit material — the pool default (glows, tracers). */ + additiveMaterial(color: number): number { + return this.material(color, MAT.unlit | MAT.additive); + } + + // -- frame flush ----------------------------------------------------------------------- + + /** + * Push every changed pose (+ camera) and pool in ONE batch. Call once per + * frame after the sim step — playset's game loop does this for you. + */ + flush(): void { + const ops = this.__ops; + if (!ops) return; + let count = 0; + const need = (this.nodes.length + 1) * POSE_STRIDE; + if (this.poseBuf.length < need) { + this.poseBuf = new Float32Array(Math.ceil(need * 1.5)); + } + for (const n of this.nodes) { + if (!n.__alive) continue; + if (!poseDirty(n.__last, n.position, n.quaternion, n.scale)) continue; + n.__last ??= new Float64Array(10); + writePose(n.__last, n.position, n.quaternion, n.scale); + const o = count * POSE_STRIDE; + this.poseBuf[o] = n.__id; + this.poseBuf[o + 1] = n.position.x; + this.poseBuf[o + 2] = n.position.y; + this.poseBuf[o + 3] = n.position.z; + this.poseBuf[o + 4] = n.quaternion.x; + this.poseBuf[o + 5] = n.quaternion.y; + this.poseBuf[o + 6] = n.quaternion.z; + this.poseBuf[o + 7] = n.quaternion.w; + this.poseBuf[o + 8] = n.scale.x; + this.poseBuf[o + 9] = n.scale.y; + this.poseBuf[o + 10] = n.scale.z; + count++; + } + if (count > 0) ops.writePoses(this.poseBuf, count); + const cam = this.camera; + if (poseDirty(cam.__last, cam.position, cam.quaternion, LENS.set(cam.fovY, cam.znear, cam.zfar))) { + cam.__last ??= new Float64Array(10); + writePose(cam.__last, cam.position, cam.quaternion, LENS); + ops.camera( + this.__scene, + cam.position.x, cam.position.y, cam.position.z, + cam.quaternion.x, cam.quaternion.y, cam.quaternion.z, cam.quaternion.w, + cam.fovY, cam.znear, cam.zfar, + ); + } + for (const p of this.pools) p.__flush(); + } + + /** Bind to a laid-out `ui` node (Viewport3D does this for you). */ + bindViewport(uiNodeId: number): void { + this.__ops?.bindViewport(uiNodeId, this.__scene); + } + unbindViewport(uiNodeId: number): void { + this.__ops?.bindViewport(uiNodeId, 0); + } + + destroy(): void { + this.__ops?.sceneDestroy(this.__scene); + this.nodes.length = 0; + this.pools.length = 0; + } +} + +// Scratch Vector3 that carries (fovY, znear, zfar) through the pose differ. +const LENS = new Vector3(); + +function poseDirty(last: Float64Array | null, p: Vector3, q: Quaternion, s: Vector3): boolean { + if (!last) return true; + return ( + last[0] !== p.x || last[1] !== p.y || last[2] !== p.z || + last[3] !== q.x || last[4] !== q.y || last[5] !== q.z || last[6] !== q.w || + last[7] !== s.x || last[8] !== s.y || last[9] !== s.z + ); +} + +function writePose(last: Float64Array, p: Vector3, q: Quaternion, s: Vector3): void { + last[0] = p.x; last[1] = p.y; last[2] = p.z; + last[3] = q.x; last[4] = q.y; last[5] = q.z; last[6] = q.w; + last[7] = s.x; last[8] = s.y; last[9] = s.z; +} diff --git a/playset/scene3d/index.ts b/playset/scene3d/index.ts new file mode 100644 index 0000000..7a2b141 --- /dev/null +++ b/playset/scene3d/index.ts @@ -0,0 +1,14 @@ +// @pocketjs/playset/scene3d — the scene3d surface: contract, client, viewport. + +export { + GEOM_KIND, + MAT, + POSE_STRIDE, + SPRITE_STRIDE, + BEAM_STRIDE, + detectScene3d, + type Scene3dOps, +} from "./ops.ts"; +export { Scene3D, SceneNode, Camera3D, SpritePool, BeamPool } from "./client.ts"; +export { Viewport3D, type Viewport3DProps } from "./viewport.ts"; +export { createScene3dSim } from "./sim.ts"; diff --git a/playset/scene3d/ops.ts b/playset/scene3d/ops.ts new file mode 100644 index 0000000..9757dff --- /dev/null +++ b/playset/scene3d/ops.ts @@ -0,0 +1,204 @@ +// playset/scene3d/ops.ts — the `scene3d` surface contract (RUNTIMES.md §3). +// +// scene3d is a CLOSED PRESENTATION VOCABULARY for real-time 3D mini-games — +// the 3D sibling of the `ui` surface. It is deliberately NOT a universal +// scene graph and NOT Three.js-compatible: every verb here is expressible on +// a fixed-function GPU (PSP GE) as well as on wgpu/Metal, and every verb is +// a WRITE. There are no reads: picking, collision and visibility queries are +// the guest's job (playset ships deterministic TS colliders), so a frame's +// pixels are a pure function of the op stream — no FFI round-trips on hot +// paths, no hidden inputs, and the whole surface replays under host-sim. +// +// Hosts install this namespace as `globalThis.s3` (native) or inject it +// (web/wasm/test), exactly like the `ui` surface in src/host.ts. Hosts +// without 3D support simply omit it — degrades to an empty +// laid-out box, the same graceful-absence contract as