diff --git a/README.md b/README.md index 2edf3f1..6daa8dc 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ npm run server ``` The server rebuilds `@yawn/core` and `@yawn/handles` at startup and serves the public CDN modules at -`https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Their workers and -WASM stay on the CDN automatically, so applications only import the module URLs. The complete editor is at `/playground`; saved revisions use +`https://yawn.heaust.org/pkg/core.js` and `https://yawn.heaust.org/pkg/handles.js`. Handles keeps Core +as a separate module dependency instead of bundling it. Map and import both URLs as shown in +`server/examples/basic.html`; their workers and WASM stay on the CDN automatically. The complete editor is at `/playground`; saved revisions use `/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and `server/data/yawn.db` database path. diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 82e5a69..90e46a6 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -62,10 +62,14 @@ fn main(@builtin(global_invocation_id) id: vec3) { if (id.x != 0u) { return; } var count = 0u; var light = vec3(0.0); + var pointLight = vec3(0.0); + var spotLight = vec3(0.0); for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { let enabled = pointLights[i * 2u + 1u].y; count += select(0u, 1u, enabled != 0.0); - light += pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; + let contribution = pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02; + light += contribution; + pointLight += contribution; } for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { let enabled = rectLights[i * 3u + 1u].z; @@ -75,7 +79,9 @@ fn main(@builtin(global_invocation_id) id: vec3) { for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let enabled = spotLights[i * 2u + 1u].w; count += select(0u, 1u, enabled != 0.0); - light += spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; + let contribution = spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02; + light += contribution; + spotLight += contribution; } for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) { let enabled = directionalLights[i * 2u + 1u].x; @@ -90,6 +96,12 @@ fn main(@builtin(global_invocation_id) id: vec3) { clusters[1] = bitcast(light.r); clusters[2] = bitcast(light.g); clusters[3] = bitcast(light.b); + clusters[4] = bitcast(pointLight.r); + clusters[5] = bitcast(pointLight.g); + clusters[6] = bitcast(pointLight.b); + clusters[7] = bitcast(spotLight.r); + clusters[8] = bitcast(spotLight.g); + clusters[9] = bitcast(spotLight.b); }`; const basicForwardShader = /* wgsl */ ` @@ -173,6 +185,8 @@ struct VertexOutput { @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; @group(0) @binding(7) var cameraMatrices: array>; +@group(0) @binding(8) var pointLights: array>; +@group(0) @binding(9) var spotLights: array>; ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d;" : ""} ${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d;" : ""} @@ -235,10 +249,38 @@ fn fragment(input: VertexOutput) -> @location(0) vec4 { let f0 = mix(vec3(0.04), base.rgb, metallic); let specular = f0 * pow(nDotH, max(2.0, 2.0 / (roughness * roughness) - 2.0)); let clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); - let ambient = vec3(0.08) + clusterLight; - let diffuse = base.rgb * (1.0 - metallic) * nDotL; + let pointAmbient = vec3(bitcast(clusters[4]), bitcast(clusters[5]), bitcast(clusters[6])); + let spotAmbient = vec3(bitcast(clusters[7]), bitcast(clusters[8]), bitcast(clusters[9])); + let ambient = vec3(0.08) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0)); + var pointDiffuse = vec3(0.0); + for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { + let colorIntensity = pointLights[i * 2u]; + let state = pointLights[i * 2u + 1u]; + if (state.y == 0.0 || state.x <= 0.0) { continue; } + let offset = positions[i].xyz - input.world; + let distance = length(offset); + let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0); + let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0); + pointDiffuse += colorIntensity.rgb * colorIntensity.a * attenuation * incidence * 0.08; + } + var spotDiffuse = vec3(0.0); + for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { + let colorIntensity = spotLights[i * 2u]; + let state = spotLights[i * 2u + 1u]; + if (state.w == 0.0 || state.x <= 0.0) { continue; } + let fromLight = input.world - positions[i].xyz; + let distance = length(fromLight); + let lightDirection = normalize(rotate(rotors[i], vec3(0.0, 0.0, -1.0))); + let cone = smoothstep(cos(state.z), cos(state.y), dot(lightDirection, fromLight / max(distance, 0.0001))); + let attenuation = pow(max(1.0 - distance / state.x, 0.0), 2.0); + let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0); + spotDiffuse += colorIntensity.rgb * colorIntensity.a * cone * attenuation * incidence * 0.08; + } + let defaultLight = select(1.0, 0.18, clusters[0] != 0u); + let diffuse = base.rgb * (1.0 - metallic) * nDotL * defaultLight; let emissive = vec3(properties.z, properties.w, extra.x); - return vec4((base.rgb * ambient + diffuse + specular * nDotL + emissive) * accent.a, base.a); + let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse); + return vec4((base.rgb * ambient + diffuse + localDiffuse + specular * nDotL * defaultLight + emissive) * accent.a, base.a); }`; } @@ -1049,6 +1091,12 @@ export class Scene { binding, resource, })), + ...(detailed + ? [ + { group: 0, binding: 8, resource: "point-lights" }, + { group: 0, binding: 9, resource: "spot-lights" }, + ] + : []), ...(detailed && mask ? [ { group: 1, binding: 0, resource: "material-linear" }, diff --git a/addons/handles/src/index.ts b/addons/handles/src/index.ts index d991954..df92f66 100644 --- a/addons/handles/src/index.ts +++ b/addons/handles/src/index.ts @@ -2,6 +2,7 @@ export * from "./Scene"; export * from "./ComputePass"; export * from "./Node"; export * from "./Mesh"; +export * from "./shapes"; export * from "./camera/Camera"; export * from "./camera/ArcRotateCamera"; export * from "./camera/FreeCamera"; diff --git a/addons/handles/src/shapes/Cube.ts b/addons/handles/src/shapes/Cube.ts new file mode 100644 index 0000000..c4ad870 --- /dev/null +++ b/addons/handles/src/shapes/Cube.ts @@ -0,0 +1,57 @@ +import { Mesh, type MeshOptions } from "../Mesh"; +import type { Scene } from "../Scene"; + +export type CubeOptions = Omit & { + size?: number; + width?: number; + height?: number; + depth?: number; +}; + +function dimension(value: number, name: string) { + if (!Number.isFinite(value) || value <= 0) throw new RangeError(name); + return value / 2; +} + +/** A six-sided mesh with independent vertices and normals for every face. */ +export class Cube extends Mesh { + constructor(scene: Scene, options: CubeOptions = {}) { + const { + size = 1, + width = size, + height = size, + depth = size, + ...meshOptions + } = options; + const x = dimension(width, "width"); + const y = dimension(height, "height"); + const z = dimension(depth, "depth"); + const positions = [ + [-x, -y, z, x, -y, z, x, y, z, -x, y, z], + [x, -y, -z, -x, -y, -z, -x, y, -z, x, y, -z], + [x, -y, z, x, -y, -z, x, y, -z, x, y, z], + [-x, -y, -z, -x, -y, z, -x, y, z, -x, y, -z], + [-x, y, z, x, y, z, x, y, -z, -x, y, -z], + [-x, -y, -z, x, -y, -z, x, -y, z, -x, -y, z], + ].flat(); + const normals = [ + [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + [0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1], + [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0], + [-1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0], + [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], + [0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0], + ].flat(); + const uvs = Array.from({ length: 6 }, () => [ + 0, 0, 1, 0, 1, 1, 0, 1, + ]).flat(); + const indices = Array.from({ length: 6 }, (_, face) => { + const vertex = face * 4; + return [vertex, vertex + 1, vertex + 2, vertex, vertex + 2, vertex + 3]; + }).flat(); + super(scene, { + ...meshOptions, + vertexData: { positions, normals, uvs, indices }, + }); + } +} diff --git a/addons/handles/src/shapes/Icosphere.ts b/addons/handles/src/shapes/Icosphere.ts new file mode 100644 index 0000000..53622a9 --- /dev/null +++ b/addons/handles/src/shapes/Icosphere.ts @@ -0,0 +1,100 @@ +import { Mesh, type MeshOptions } from "../Mesh"; +import type { Scene } from "../Scene"; + +export type IcosphereOptions = Omit< + MeshOptions, + "geometryId" | "vertexData" +> & { + radius?: number; + subdivisions?: number; +}; + +function normalized(vertex: number[]) { + const length = Math.hypot(...vertex); + return vertex.map((lane) => lane / length); +} + +/** A sphere produced by recursively subdividing an icosahedron. */ +export class Icosphere extends Mesh { + constructor(scene: Scene, options: IcosphereOptions = {}) { + const { radius = 0.5, subdivisions = 2, ...meshOptions } = options; + if (!Number.isFinite(radius) || radius <= 0) throw new RangeError("radius"); + if (!Number.isInteger(subdivisions) || subdivisions < 0 || subdivisions > 6) + throw new RangeError("subdivisions"); + + const golden = (1 + Math.sqrt(5)) / 2; + const vertices = [ + [-1, golden, 0], + [1, golden, 0], + [-1, -golden, 0], + [1, -golden, 0], + [0, -1, golden], + [0, 1, golden], + [0, -1, -golden], + [0, 1, -golden], + [golden, 0, -1], + [golden, 0, 1], + [-golden, 0, -1], + [-golden, 0, 1], + ].map(normalized); + let faces = [ + [0, 11, 5], + [0, 5, 1], + [0, 1, 7], + [0, 7, 10], + [0, 10, 11], + [1, 5, 9], + [5, 11, 4], + [11, 10, 2], + [10, 7, 6], + [7, 1, 8], + [3, 9, 4], + [3, 4, 2], + [3, 2, 6], + [3, 6, 8], + [3, 8, 9], + [4, 9, 5], + [2, 4, 11], + [6, 2, 10], + [8, 6, 7], + [9, 8, 1], + ]; + + for (let level = 0; level < subdivisions; level++) { + const midpoints = new Map(); + const midpoint = (left: number, right: number) => { + const key = left < right ? `${left}:${right}` : `${right}:${left}`; + const existing = midpoints.get(key); + if (existing !== undefined) return existing; + const vertex = normalized( + vertices[left].map((lane, index) => lane + vertices[right][index]), + ); + const index = vertices.push(vertex) - 1; + midpoints.set(key, index); + return index; + }; + faces = faces.flatMap(([a, b, c]) => { + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const ca = midpoint(c, a); + return [ + [a, ab, ca], + [b, bc, ab], + [c, ca, bc], + [ab, bc, ca], + ]; + }); + } + + const normals = vertices.flat(); + const positions = normals.map((lane) => lane * radius); + const uvs = vertices.flatMap(([x, y, z]) => [ + 0.5 + Math.atan2(z, x) / (Math.PI * 2), + 0.5 - Math.asin(y) / Math.PI, + ]); + super(scene, { + ...meshOptions, + vertexData: { positions, normals, uvs, indices: faces.flat() }, + }); + } +} diff --git a/addons/handles/src/shapes/Plane.ts b/addons/handles/src/shapes/Plane.ts new file mode 100644 index 0000000..1187834 --- /dev/null +++ b/addons/handles/src/shapes/Plane.ts @@ -0,0 +1,31 @@ +import { Mesh, type MeshOptions } from "../Mesh"; +import type { Scene } from "../Scene"; + +export type PlaneOptions = Omit & { + size?: number; + width?: number; + depth?: number; +}; + +function dimension(value: number, name: string) { + if (!Number.isFinite(value) || value <= 0) throw new RangeError(name); + return value / 2; +} + +/** A horizontal, upward-facing plane centered on its transform. */ +export class Plane extends Mesh { + constructor(scene: Scene, options: PlaneOptions = {}) { + const { size = 1, width = size, depth = size, ...meshOptions } = options; + const x = dimension(width, "width"); + const z = dimension(depth, "depth"); + super(scene, { + ...meshOptions, + vertexData: { + positions: [-x, 0, -z, x, 0, -z, x, 0, z, -x, 0, z], + normals: [0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], + uvs: [0, 0, 1, 0, 1, 1, 0, 1], + indices: [0, 2, 1, 0, 3, 2], + }, + }); + } +} diff --git a/addons/handles/src/shapes/Sphere.ts b/addons/handles/src/shapes/Sphere.ts new file mode 100644 index 0000000..8053f80 --- /dev/null +++ b/addons/handles/src/shapes/Sphere.ts @@ -0,0 +1,56 @@ +import { Mesh, type MeshOptions } from "../Mesh"; +import type { Scene } from "../Scene"; + +export type SphereOptions = Omit & { + radius?: number; + segments?: number; + rings?: number; +}; + +function count(value: number, minimum: number, name: string) { + if (!Number.isInteger(value) || value < minimum) throw new RangeError(name); + return value; +} + +/** A UV sphere with configurable horizontal segments and vertical rings. */ +export class Sphere extends Mesh { + constructor(scene: Scene, options: SphereOptions = {}) { + const { radius = 0.5, segments = 32, rings = 16, ...meshOptions } = options; + if (!Number.isFinite(radius) || radius <= 0) throw new RangeError("radius"); + const columns = count(segments, 3, "segments"); + const rows = count(rings, 2, "rings"); + const positions: number[] = []; + const normals: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + + for (let ring = 0; ring <= rows; ring++) { + const vertical = ring / rows; + const theta = vertical * Math.PI; + const y = Math.cos(theta); + const scale = Math.sin(theta); + for (let segment = 0; segment <= columns; segment++) { + const horizontal = segment / columns; + const phi = horizontal * Math.PI * 2; + const x = scale * Math.cos(phi); + const z = scale * Math.sin(phi); + normals.push(x, y, z); + positions.push(x * radius, y * radius, z * radius); + uvs.push(horizontal, 1 - vertical); + } + } + + for (let ring = 0; ring < rows; ring++) { + for (let segment = 0; segment < columns; segment++) { + const current = ring * (columns + 1) + segment; + const next = current + columns + 1; + indices.push(current, current + 1, next, current + 1, next + 1, next); + } + } + + super(scene, { + ...meshOptions, + vertexData: { positions, normals, uvs, indices }, + }); + } +} diff --git a/addons/handles/src/shapes/index.ts b/addons/handles/src/shapes/index.ts new file mode 100644 index 0000000..aaa1d0e --- /dev/null +++ b/addons/handles/src/shapes/index.ts @@ -0,0 +1,4 @@ +export * from "./Cube"; +export * from "./Icosphere"; +export * from "./Plane"; +export * from "./Sphere"; diff --git a/server/build.mjs b/server/build.mjs index f0ba9d4..1b1b918 100644 --- a/server/build.mjs +++ b/server/build.mjs @@ -4,7 +4,8 @@ import process from "node:process"; import { build } from "esbuild"; const [root, output] = process.argv.slice(2); -if (!root || !output) throw new Error("usage: node build.mjs "); +if (!root || !output) + throw new Error("usage: node build.mjs "); const browser = { bundle: true, @@ -17,8 +18,14 @@ const browser = { const workerFiles = new Map([ [path.join(root, "core", "index.js"), "./worker.js"], - [path.join(root, "addons", "handles", "src", "importers", "gltf.ts"), "./importer-worker.js"], - [path.join(root, "addons", "handles", "src", "bvh", "picking.ts"), "./bvh-worker.js"], + [ + path.join(root, "addons", "handles", "src", "importers", "gltf.ts"), + "./importer-worker.js", + ], + [ + path.join(root, "addons", "handles", "src", "bvh", "picking.ts"), + "./bvh-worker.js", + ], ]); const workerURLs = { @@ -32,7 +39,8 @@ const workerURLs = { /new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/, `createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`, ); - if (transformed === contents) throw new Error(`worker constructor not found in ${source}`); + if (transformed === contents) + throw new Error(`worker constructor not found in ${source}`); contents = ` function createYawnWorker(url, options) { if (url.origin === globalThis.location.origin) return new Worker(url, options); @@ -57,8 +65,8 @@ await Promise.all([ }), build({ ...browser, - alias: { "@yawn/core": path.join(root, "core", "index.js") }, entryPoints: [path.join(root, "addons", "handles", "src", "index.ts")], + external: ["@yawn/core"], outfile: path.join(output, "handles.js"), plugins: [workerURLs], }), @@ -73,12 +81,16 @@ await Promise.all([ }), build({ ...browser, - entryPoints: [path.join(root, "addons", "handles", "src", "importers", "worker.ts")], + entryPoints: [ + path.join(root, "addons", "handles", "src", "importers", "worker.ts"), + ], outfile: path.join(output, "importer-worker.js"), }), build({ ...browser, - entryPoints: [path.join(root, "addons", "handles", "src", "bvh", "worker.ts")], + entryPoints: [ + path.join(root, "addons", "handles", "src", "bvh", "worker.ts"), + ], outfile: path.join(output, "bvh-worker.js"), }), ]); diff --git a/server/examples/basic.html b/server/examples/basic.html new file mode 100644 index 0000000..e7760be --- /dev/null +++ b/server/examples/basic.html @@ -0,0 +1,84 @@ + + + + + + + Yawn CDN example + + + + +
+ +

Starting Yawn…

+
+ + + + diff --git a/server/web/assets/playground.js b/server/web/assets/playground.js index 4053376..8658261 100644 --- a/server/web/assets/playground.js +++ b/server/web/assets/playground.js @@ -1,60 +1,98 @@ const defaultSource = `import { + AmbientLight, ArcRotateCamera, ColorGrading, FXAA, - Mesh, + Icosphere, PBRMaterial, + Plane, PointLight, Scene, + SpotLight, } from "@yawn/handles"; const scene = new Scene(canvas, { hdr: true, fps: 60 }); await scene.ready; +await scene.reserve({ nodes: 6, materials: 2 }); -const material = new PBRMaterial(scene, { - baseColor: [0.12, 0.58, 1, 1], - metallic: 0.15, - roughness: 0.35, +const objectMaterial = new PBRMaterial(scene, { + baseColor: [0.82, 0.86, 0.9, 1], + metallic: 0.12, + roughness: 0.32, }); -await material.ready; - -const mesh = new Mesh(scene, { - material, - vertexData: { - positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0], - normals: [0, 0, 1, 0, 0, 1, 0, 0, 1], - indices: [0, 1, 2], - }, +const groundMaterial = new PBRMaterial(scene, { + baseColor: [0.24, 0.28, 0.34, 1], + metallic: 0, + roughness: 0.82, }); -await mesh.ready; +await Promise.all([objectMaterial.ready, groundMaterial.ready]); -log("Scene ready — drag your pointer across the preview."); -const move = (event: PointerEvent) => { - const bounds = canvas.getBoundingClientRect(); - mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.2; - mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 0.8; -}; -canvas.addEventListener("pointermove", move); +const icosphere = new Icosphere(scene, { + material: objectMaterial, + subdivisions: 2, + position: [0, 0.2, 0], +}); +const ground = new Plane(scene, { + material: groundMaterial, + size: 2.6, + position: [0, -0.55, 0], +}); +await Promise.all([icosphere.ready, ground.ready]); +icosphere.rotateY(Math.PI / 5); + +const camera = new ArcRotateCamera(scene, { + target: icosphere, + alpha: Math.PI / 4, + beta: Math.PI / 3, + radius: 4, + aspect: canvas.width / canvas.height, + controls: { element: canvas, pointer: true }, +}); +await camera.ready; + +const lights = [ + new AmbientLight(scene, { color: [0.02, 0.45, 0.48], intensity: 0.5 }), + new SpotLight(scene, { + position: [-2.8, 3, -2.8], + rotor: [-0.628, -0.628, 0, 0.4597], + color: [1, 0.22, 0.03], + intensity: 220, + range: 7, + innerAngle: 0.32, + outerAngle: 0.72, + }), + new PointLight(scene, { + position: [2.2, 0.9, 1.8], + color: [1, 0.02, 0.45], + intensity: 16, + range: 6, + }), +]; +await Promise.all(lights.map((light) => light.ready)); + +log("Icosphere ready — teal ambience, orange rim, and magenta accent are active."); export default { scene, - mesh, + icosphere, + ground, + lights, dispose() { - canvas.removeEventListener("pointermove", move); + camera.detachControls(); scene.dispose(); }, };`; const snippets = { camera: `// Orbit camera — drag to orbit and use the wheel to zoom. -const camera = new ArcRotateCamera(scene, { - target: mesh, +const orbitCamera = new ArcRotateCamera(scene, { + target: icosphere, alpha: 0, beta: Math.PI / 2, radius: 3, controls: { element: canvas, pointer: true }, }); -await camera.ready;`, +await orbitCamera.ready;`, light: `// A warm point light backed by shared rows. const light = new PointLight(scene, { position: [0, 0.7, 1], @@ -65,7 +103,7 @@ const light = new PointLight(scene, { await light.ready;`, instances: `// Clones share geometry until one clone changes its vertex data. for (let x = -2; x <= 2; x++) { - const instance = mesh.clone({ position: [x * 0.35, 0, 0] }); + const instance = icosphere.clone({ position: [x * 0.7, 0.2, 0] }); await instance.ready; }`, rows: `// Application-owned hot data in the same shared arena. @@ -123,9 +161,59 @@ const elements = Object.fromEntries( ); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; -const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).then( - ([handles, core]) => ({ ...handles, ...core }), -); +const nativeSetTimeout = window.setTimeout.bind(window); +const nativeSetInterval = window.setInterval.bind(window); +const nativeClearTimeout = window.clearTimeout.bind(window); +const nativeClearInterval = window.clearInterval.bind(window); +const playgroundTimerIDs = new Set(); + +function trackedSetTimeout(callback, delay, ...arguments_) { + let id; + const wrapped = + typeof callback === "function" + ? (...callbackArguments) => { + playgroundTimerIDs.delete(id); + callback.apply(window, callbackArguments); + } + : callback; + id = nativeSetTimeout(wrapped, delay, ...arguments_); + playgroundTimerIDs.add(id); + return id; +} + +function trackedSetInterval(callback, delay, ...arguments_) { + const id = nativeSetInterval(callback, delay, ...arguments_); + playgroundTimerIDs.add(id); + return id; +} + +function trackedClearTimer(id) { + playgroundTimerIDs.delete(id); + nativeClearTimeout(id); + nativeClearInterval(id); +} + +function clearPlaygroundTimers() { + for (const id of playgroundTimerIDs) { + nativeClearTimeout(id); + nativeClearInterval(id); + } + playgroundTimerIDs.clear(); +} + +window.__yawnPlaygroundTimers = { + ids: playgroundTimerIDs, + setTimeout: trackedSetTimeout, + setInterval: trackedSetInterval, + clearTimeout: trackedClearTimer, + clearInterval: trackedClearTimer, + clear: clearPlaygroundTimers, +}; + +const packages = Promise.all([ + import("@yawn/handles"), + import("@yawn/core"), +]).then(([handles, core]) => ({ ...handles, ...core })); let monaco; let editor; let model; @@ -143,8 +231,14 @@ let frameRequest; let sampledFrame = 0; let sampledAt = 0; +addEventListener("yawn-theme-change", (event) => { + monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs"); +}); + function savedLocation() { - const match = location.pathname.match(/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/); + const match = location.pathname.match( + /^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/, + ); return match ? { id: match[1], revision: Number(match[2]) } : undefined; } @@ -152,8 +246,11 @@ async function loadInitialPlayground() { const saved = savedLocation(); if (saved) { try { - const response = await fetch(`/api/playgrounds/${saved.id}/${saved.revision}`); - if (!response.ok) throw new Error("This saved playground could not be found."); + const response = await fetch( + `/api/playgrounds/${saved.id}/${saved.revision}`, + ); + if (!response.ok) + throw new Error("This saved playground could not be found."); const result = await response.json(); currentID = result.id; currentRevision = result.revision; @@ -188,13 +285,19 @@ importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)}); ); window.MonacoEnvironment = { getWorkerUrl: () => workerURL }; window.require.config({ paths: { vs: `${monacoBase}vs` } }); - window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject); + window.require( + ["vs/editor/editor.main"], + () => resolve(window.monaco), + reject, + ); }); } async function createEditor(source) { monaco = await loadMonaco(); - const types = await fetch("/assets/yawn.d.ts").then((response) => response.text()); + const types = await fetch("/assets/yawn.d.ts").then((response) => + response.text(), + ); const defaults = monaco.languages.typescript.typescriptDefaults; defaults.setEagerModelSync(true); defaults.setCompilerOptions({ @@ -205,7 +308,10 @@ async function createEditor(source) { strict: true, target: monaco.languages.typescript.ScriptTarget.ESNext, }); - defaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false }); + defaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }); defaults.addExtraLib(types, "file:///types/yawn.d.ts"); monaco.editor.defineTheme("yawn", { base: "vs-dark", @@ -232,12 +338,17 @@ async function createEditor(source) { }, }); elements.editor.textContent = ""; - model = monaco.editor.createModel(source, "typescript", monaco.Uri.parse("file:///playground/scene.ts")); + model = monaco.editor.createModel( + source, + "typescript", + monaco.Uri.parse("file:///playground/scene.ts"), + ); editor = monaco.editor.create(elements.editor, { model, - theme: "yawn", + theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs", automaticLayout: true, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", fontLigatures: true, fontSize: 12, lineHeight: 20, @@ -256,11 +367,13 @@ async function createEditor(source) { run, }); editor.onDidChangeCursorPosition(({ position }) => { - elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`; + elements["cursor-status"].textContent = + `Ln ${position.lineNumber}, Col ${position.column}`; }); model.onDidChangeContent(() => { markDirty(true); - if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue()); + if (!currentID) + localStorage.setItem("yawn:playground:draft", model.getValue()); }); monaco.editor.onDidChangeMarkers(([resource]) => { if (resource.toString() === model.uri.toString()) updateDiagnostics(); @@ -272,8 +385,12 @@ async function createEditor(source) { function updateDiagnostics() { if (!monaco || !model) return; const markers = monaco.editor.getModelMarkers({ resource: model.uri }); - const errors = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Error).length; - const warnings = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Warning).length; + const errors = markers.filter( + (marker) => marker.severity === monaco.MarkerSeverity.Error, + ).length; + const warnings = markers.filter( + (marker) => marker.severity === monaco.MarkerSeverity.Warning, + ).length; const total = errors + warnings; elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0); elements["editor-diagnostics"].lastChild.textContent = total @@ -295,7 +412,8 @@ async function transpile() { const client = await worker(model.uri); const emitted = await client.getEmitOutput(model.uri.toString()); const output = emitted.outputFiles.find((file) => file.name.endsWith(".js")); - if (emitted.emitSkipped || !output) throw new Error("TypeScript could not emit this scene."); + if (emitted.emitSkipped || !output) + throw new Error("TypeScript could not emit this scene."); return output.text .replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "") .replace(/^\s*export\s*\{\s*\};?\s*$/gm, "") @@ -329,33 +447,56 @@ function replaceCanvas() { canvas.setAttribute("aria-label", "Yawn WebGPU preview"); const scale = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale)); - canvas.height = Math.max(1, Math.round(elements.preview.clientHeight * scale)); + canvas.height = Math.max( + 1, + Math.round(elements.preview.clientHeight * scale), + ); previous.replaceWith(canvas); elements.canvas = canvas; - elements["resolution-status"].textContent = `${canvas.width} × ${canvas.height}`; + elements["resolution-status"].textContent = + `${canvas.width} × ${canvas.height}`; return canvas; } async function run() { if (!editor || elements["run-button"].disabled) return; + clearPlaygroundTimers(); const runID = ++generation; elements["run-button"].disabled = true; elements["run-button"].lastChild.textContent = " Running"; clearConsole(); setRunStatus("Compiling", "running"); - showPreviewMessage("Compiling TypeScript", "Checking types and preparing the render graph."); + showPreviewMessage( + "Compiling TypeScript", + "Checking types and preparing the render graph.", + ); try { - if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled on this page."); - if (!navigator.gpu) throw new Error("WebGPU is not available in this browser."); + if (!crossOriginIsolated) + throw new Error("Cross-origin isolation is disabled on this page."); + if (!navigator.gpu) + throw new Error("WebGPU is not available in this browser."); const [code, api] = await Promise.all([transpile(), packages]); await disposeCurrent(); const canvas = replaceCanvas(); const names = Object.keys(api); const started = performance.now(); - const result = await new AsyncFunction(...names, "canvas", "log", code)( + const result = await new AsyncFunction( + ...names, + "canvas", + "log", + "setTimeout", + "setInterval", + "clearTimeout", + "clearInterval", + code, + )( ...names.map((name) => api[name]), canvas, (...values) => appendConsole(values.map(formatValue).join(" ")), + trackedSetTimeout, + trackedSetInterval, + trackedClearTimer, + trackedClearTimer, ); if (runID !== generation) { if (result?.dispose) await result.dispose(); @@ -364,7 +505,10 @@ async function run() { current = result; window.__yawnPlayground = result; elements["preview-message"].hidden = true; - setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready"); + setRunStatus( + `Running · ${Math.round(performance.now() - started)} ms`, + "ready", + ); if (activePanel === "profile") await attachProfiler(); } catch (error) { if (runID === generation) { @@ -377,7 +521,8 @@ async function run() { } finally { if (runID === generation) { elements["run-button"].disabled = false; - elements["run-button"].innerHTML = ' Run Ctrl ↵'; + elements["run-button"].innerHTML = + ' Run Ctrl ↵'; } } } @@ -396,7 +541,10 @@ async function attachProfiler() { } stopProfile = profileCore.onProfile(renderProfile); const supported = await profileCore.setProfiler(true); - if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter."); + if (!supported) + setProfileMessage( + "Timestamp queries are not supported by this GPU adapter.", + ); } function setProfileMessage(message) { @@ -408,8 +556,10 @@ function setProfileMessage(message) { function renderProfile(profile) { elements["profile-empty"].hidden = true; elements["profile-results"].hidden = false; - elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`; - elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`; + elements["profile-total"].textContent = + `${profile.milliseconds.toFixed(2)} ms`; + elements["profile-latency"].textContent = + `${profile.readbackMilliseconds.toFixed(2)} ms`; elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter"; elements["profile-adapter"].title = profile.adapter || "WebGPU adapter"; elements["profile-passes"].replaceChildren( @@ -441,7 +591,8 @@ function appendConsole(message, error = false) { line.textContent = message; line.classList.toggle("error", error); elements["console-lines"].append(line); - elements["console-count"].textContent = elements["console-lines"].children.length; + elements["console-count"].textContent = + elements["console-lines"].children.length; line.scrollIntoView({ block: "nearest" }); } @@ -452,7 +603,8 @@ function clearConsole() { } function setRunStatus(message, state) { - elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : ""; + elements["run-status"].className = + state === "running" ? "running" : state === "failed" ? "failed" : ""; elements["run-status"].lastChild.textContent = ` ${message}`; } @@ -466,7 +618,9 @@ function openInspector(panel) { activePanel = panel; elements.inspector.classList.remove("collapsed"); elements["inspector-button"].setAttribute("aria-pressed", "true"); - for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { + for (const button of document.querySelectorAll( + ".inspector-tabs [data-panel]", + )) { const active = button.dataset.panel === panel; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); @@ -497,7 +651,8 @@ async function save() { }), }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || "The playground could not be saved."); + if (!response.ok) + throw new Error(result.error || "The playground could not be saved."); currentID = result.id; currentRevision = result.revision; initialSource = result.code; @@ -518,13 +673,22 @@ function showToast(message) { clearTimeout(toastTimer); elements.toast.textContent = message; elements.toast.classList.add("visible"); - toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2400); + toastTimer = setTimeout( + () => elements.toast.classList.remove("visible"), + 2400, + ); } function insertSnippet(name) { const selection = editor.getSelection(); const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n"; - editor.executeEdits("snippet", [{ range: selection, text: `${prefix}${snippets[name]}\n`, forceMoveMarkers: true }]); + editor.executeEdits("snippet", [ + { + range: selection, + text: `${prefix}${snippets[name]}\n`, + forceMoveMarkers: true, + }, + ]); elements["snippets-dialog"].close(); editor.focus(); } @@ -539,7 +703,8 @@ function sampleFPS(now = performance.now()) { sampledFrame = frame; } else if (now - sampledAt >= 500) { const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt); - elements["fps-status"].textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`; + elements["fps-status"].textContent = + `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`; sampledAt = now; sampledFrame = frame; } @@ -562,22 +727,34 @@ elements["reset-button"].addEventListener("click", () => { model?.setValue(initialSource); void run(); }); -elements["format-button"].addEventListener("click", () => editor?.getAction("editor.action.formatDocument").run()); -elements["snippets-button"].addEventListener("click", () => elements["snippets-dialog"].showModal()); -elements["snippets-dialog"].querySelector("[data-close-dialog]").addEventListener("click", () => elements["snippets-dialog"].close()); +elements["format-button"].addEventListener("click", () => + editor?.getAction("editor.action.formatDocument").run(), +); +elements["snippets-button"].addEventListener("click", () => + elements["snippets-dialog"].showModal(), +); +elements["snippets-dialog"] + .querySelector("[data-close-dialog]") + .addEventListener("click", () => elements["snippets-dialog"].close()); elements["snippets-dialog"].addEventListener("click", (event) => { - if (event.target === elements["snippets-dialog"]) elements["snippets-dialog"].close(); + if (event.target === elements["snippets-dialog"]) + elements["snippets-dialog"].close(); }); for (const button of document.querySelectorAll("[data-snippet]")) { button.addEventListener("click", () => insertSnippet(button.dataset.snippet)); } -elements["snippets-dialog"].querySelector("input").addEventListener("input", (event) => { - const query = event.target.value.trim().toLowerCase(); - for (const button of document.querySelectorAll("[data-snippet]")) { - button.hidden = Boolean(query) && !button.textContent.toLowerCase().includes(query); - } -}); -for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { +elements["snippets-dialog"] + .querySelector("input") + .addEventListener("input", (event) => { + const query = event.target.value.trim().toLowerCase(); + for (const button of document.querySelectorAll("[data-snippet]")) { + button.hidden = + Boolean(query) && !button.textContent.toLowerCase().includes(query); + } + }); +for (const button of document.querySelectorAll( + ".inspector-tabs [data-panel]", +)) { button.addEventListener("click", () => openInspector(button.dataset.panel)); } elements["clear-console"].addEventListener("click", clearConsole); @@ -598,7 +775,10 @@ elements["resize-handle"].addEventListener("pointerdown", (event) => { elements["resize-handle"].addEventListener("pointermove", (event) => { if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return; const bounds = elements.workbench.getBoundingClientRect(); - const percentage = Math.min(70, Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100)); + const percentage = Math.min( + 70, + Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100), + ); elements.workbench.style.setProperty("--editor-width", `${percentage}%`); }); elements["resize-handle"].addEventListener("pointerup", (event) => { diff --git a/server/web/assets/theme.css b/server/web/assets/theme.css new file mode 100644 index 0000000..927c710 --- /dev/null +++ b/server/web/assets/theme.css @@ -0,0 +1,457 @@ +.theme-select { + width: 72px; + height: 32px; + padding: 0 7px; + color: #53667c; + border: 1px solid #d4e1ea; + border-radius: 6px; + outline: 0; + background: #fff; + cursor: pointer; + font-size: 11px; + font-weight: 650; +} +.theme-select:hover { + color: #177fc2; + border-color: #add5ee; +} +.theme-select:focus-visible { + border-color: #62b8eb; + box-shadow: 0 0 0 3px rgb(50 166 232 / 14%); +} +.tool-context { + font-size: 10px; +} +.stage-meta, +.editor-status { + font-size: 9px; +} + +html[data-theme="dark"] { + --ink: #d9e9f7; + --muted: #91a6b9; + --line: #20384b; + --line-strong: #315069; + --sky-25: #0a1927; + --sky-50: #0d2132; + --sky-100: #12324a; + --sky-200: #1d4c6a; + --navy: #06111d; +} +html[data-theme="dark"] body { + color: var(--ink); + background: #081522; +} +html[data-theme="dark"] .theme-select { + color: #b4c8d8; + border-color: #29465d; + background: #0e2232; +} + +html[data-theme="dark"] .site-header { + border-color: var(--line); +} +html[data-theme="dark"] .wordmark { + color: #e1eff9; +} +html[data-theme="dark"] .site-nav a, +html[data-theme="dark"] .site-footer nav a { + color: #91a7ba; +} +html[data-theme="dark"] .button-quiet { + color: #b9d1e2; + border-color: #315069; + background: #0d2030; +} +html[data-theme="dark"] .button-quiet:hover { + color: #72c8f7; + background: #102a3e; +} +html[data-theme="dark"] .hero::before { + background: radial-gradient(circle, rgb(30 102 148 / 35%), transparent 67%); +} +html[data-theme="dark"] .hero h1 { + color: #edf7ff; +} +html[data-theme="dark"] .hero-note { + color: #718ba0; +} +html[data-theme="dark"] .hero-demo { + border-color: #2a4a61; + background: #0a1927; + box-shadow: 0 30px 70px rgb(0 0 0 / 28%); +} +html[data-theme="dark"] .demo-toolbar { + color: #819bb0; + border-color: #284257; + background: #0d2131; +} +html[data-theme="dark"] .hero-demo pre { + color: #b6cad9; + background: #091827; +} +html[data-theme="dark"] .trust-strip { + background: #0a1927; +} +html[data-theme="dark"] .trust-strip strong { + color: #d7e8f5; +} +html[data-theme="dark"] .trust-strip span { + color: #8298ab; +} +html[data-theme="dark"] .gear-card { + border-color: #20394c; + background: #0b1b29; + box-shadow: 0 8px 30px rgb(0 0 0 / 16%); +} +html[data-theme="dark"] .gear-card-featured { + border-color: #285876; + background: linear-gradient(145deg, #0e2940, #0b1b29); +} +html[data-theme="dark"] .gear-number { + color: #52728a; +} +html[data-theme="dark"] .gear-icon { + border-color: #285675; + background: #0e2a40; +} +html[data-theme="dark"] .gear-card h3 { + color: #d9eaf6; +} +html[data-theme="dark"] .gear-card p { + color: #91a5b6; +} +html[data-theme="dark"] .graph-icon::before, +html[data-theme="dark"] .graph-icon::after { + background: #0e2a40; +} +html[data-theme="dark"] .steps h3 { + color: #d5e6f3; +} +html[data-theme="dark"] .steps p { + color: #8fa4b6; +} +html[data-theme="dark"] .steps code { + color: #6fc3f1; + border-color: #29465c; + background: #0c2030; +} +html[data-theme="dark"] .step-index { + border-color: #285b7a; + background: #0e2a40; +} +html[data-theme="dark"] .site-footer p { + color: #738da1; +} + +html[data-theme="dark"] .docs-body { + background: #081522; +} +html[data-theme="dark"] .docs-header { + border-color: #20384b; + background: rgb(8 21 34 / 94%); +} +html[data-theme="dark"] .docs-product { + color: #8097aa; + border-color: #294257; +} +html[data-theme="dark"] .docs-search { + color: #7891a5; + border-color: #29465b; + background: #0d2030; +} +html[data-theme="dark"] .docs-search input { + color: #d6e7f4; +} +html[data-theme="dark"] .docs-search kbd { + color: #8299ab; + border-color: #304c61; + background: #102536; +} +html[data-theme="dark"] .docs-sidebar { + border-color: #20384b; +} +html[data-theme="dark"] .sidebar-group > strong, +html[data-theme="dark"] .docs-toc > strong { + color: #708ba0; +} +html[data-theme="dark"] .sidebar-group a { + color: #91a5b7; +} +html[data-theme="dark"] .sidebar-group a:hover { + color: #69c4f5; + background: #0d2436; +} +html[data-theme="dark"] .sidebar-group a.active { + color: #70cbfb; + background: #10304a; +} +html[data-theme="dark"] .sidebar-playground { + color: #8bd4fa; + border-color: #285572; + background: #0d2940; +} +html[data-theme="dark"] .sidebar-playground small { + color: #7697ad; +} +html[data-theme="dark"] .docs-main section + section { + border-color: #20384b; +} +html[data-theme="dark"] .docs-main h1, +html[data-theme="dark"] .docs-main h2, +html[data-theme="dark"] .docs-main h3 { + color: #e0edf7; +} +html[data-theme="dark"] .docs-main p, +html[data-theme="dark"] .docs-main li { + color: #9aafc0; +} +html[data-theme="dark"] .docs-main strong { + color: #c4d8e7; +} +html[data-theme="dark"] .doc-lede { + color: #a9bdcc !important; +} +html[data-theme="dark"] .docs-main :not(pre) > code { + color: #71c6f3; + border-color: #28506a; + background: #0d293d; +} +html[data-theme="dark"] .section-kicker span { + border-color: #285a78; + background: #0e2b42; +} +html[data-theme="dark"] .callout { + border-color: #294052; + background: #0c1c2a; +} +html[data-theme="dark"] .callout-blue { + border-color: #285672; + background: #0d293e; +} +html[data-theme="dark"] .explain-grid > div { + border-color: #263f52; + background: #0b1c2a; +} +html[data-theme="dark"] .inline-play { + color: #8bd5fb; + border-color: #285974; + background: #0e2c43; +} +html[data-theme="dark"] .inline-play strong { + color: #a2defb; +} +html[data-theme="dark"] .inline-play small { + color: #789bb1; +} +html[data-theme="dark"] .mental-model > div { + border-color: #284356; + background: #0b1d2b; +} +html[data-theme="dark"] .mental-model strong { + color: #b9d0e0; +} +html[data-theme="dark"] .mental-model .mental-shared { + border-color: #2d6585; + background: #0e2c43; +} +html[data-theme="dark"] .deploy-list li { + border-color: #20384b; +} +html[data-theme="dark"] .docs-toc a { + color: #768ea1; + border-color: #294257; +} + +html[data-theme="dark"] .playground-app { + background: #07131f; +} +html[data-theme="dark"] .playground-header { + border-color: #21384b; + background: #0b1b29; +} +html[data-theme="dark"] .playground-brand { + color: #dcebf6; +} +html[data-theme="dark"] .header-divider { + background: #294154; +} +html[data-theme="dark"] .project-name input { + color: #c3d6e4; +} +html[data-theme="dark"] .project-name input:hover { + background: #102536; +} +html[data-theme="dark"] .project-name input:focus { + border-color: #2d7198; + background: #0e2638; +} +html[data-theme="dark"] #revision-label { + color: #8298aa; + border-color: #294356; + background: #0e2131; +} +html[data-theme="dark"] .playground-nav a { + color: #8da3b5; +} +html[data-theme="dark"] .tool-button { + color: #a7bdcd; + border-color: #29465c; + background: #0e2232; +} +html[data-theme="dark"] .tool-button:hover { + color: #67c4f4; + border-color: #326484; + background: #102a3d; +} +html[data-theme="dark"] .playground-tools { + border-color: #233b4e; + background: #0c1d2b; +} +html[data-theme="dark"] .tool-group button { + color: #8fa6b8; +} +html[data-theme="dark"] .tool-group button:hover, +html[data-theme="dark"] .tool-group button[aria-pressed="true"] { + color: #63c3f5; + border-color: #2b4d64; + background: #102536; +} +html[data-theme="dark"] .tool-context, +html[data-theme="dark"] .stage-meta, +html[data-theme="dark"] .editor-status { + color: #aec1cf; +} +html[data-theme="dark"] .context-divider { + background: #294154; +} +html[data-theme="dark"] .snippets-dialog { + color: #d3e3ee; + border-color: #2a4a60; + background: #0b1b29; +} +html[data-theme="dark"] .dialog-heading p { + color: #8399aa; +} +html[data-theme="dark"] .snippet-search { + color: #7890a3; + border-color: #29465b; + background: #0d2131; +} +html[data-theme="dark"] .snippet-search input { + color: #c9dae7; +} +html[data-theme="dark"] .snippet-grid > button:hover { + border-color: #2d5772; + background: #0e293d; +} +html[data-theme="dark"] .snippet-icon { + border-color: #2a5a77; + background: #0e2c43; +} +html[data-theme="dark"] .snippet-grid strong { + color: #c9dce9; +} +html[data-theme="dark"] .snippet-grid small { + color: #8298aa; +} +html[data-theme="dark"] .snippet-grid code { + color: #6bc4f2; + border-color: #29475d; + background: #0d2131; +} + +html[data-theme="light"] .workbench { + background: #d7e4ed; +} +html[data-theme="light"] .pane-tabs, +html[data-theme="light"] .stage-toolbar { + border-color: #cfdee8; + background: #edf4f8; +} +html[data-theme="light"] .pane-tab { + color: #60778a; + border-color: #d5e2ea; + background: #e8f0f5; +} +html[data-theme="light"] .pane-tab.active { + color: #29465e; + background: #f8fbfd; +} +html[data-theme="light"] .editor { + background: #fff; +} +html[data-theme="light"] .monaco-editor .margin { + background-color: #fff !important; +} +html[data-theme="light"] .editor-status { + color: #718698; + border-color: #d2e0e9; + background: #eff5f8; +} +html[data-theme="light"] .resize-handle { + background: #c8d8e3; +} +html[data-theme="light"] .resize-handle::after { + background: #8fa9bb; +} +html[data-theme="light"] .stage-pane { + background: #e7f0f5; +} +html[data-theme="light"] .stage-tabs button { + color: #60778a; +} +html[data-theme="light"] .stage-tabs button.active { + color: #284961; + background: #f8fbfd; +} +html[data-theme="light"] .stage-meta { + color: #6a8194; +} +html[data-theme="light"] .stage-divider { + background: #c5d7e2; +} +html[data-theme="light"] .inspector { + border-color: #cddde7; + background: #f7fafc; +} +html[data-theme="light"] .inspector-tabs { + border-color: #cfdee7; + background: #edf4f8; +} +html[data-theme="light"] .inspector-tabs button { + color: #687f91; +} +html[data-theme="light"] .inspector-tabs button.active { + color: #287aa9; +} +html[data-theme="light"] .inspector-tabs button span { + background: #dce9f1; +} +html[data-theme="light"] .console-empty, +html[data-theme="light"] .profile-empty { + color: #6f8799; +} +html[data-theme="light"] #console-lines { + color: #526c80; +} +html[data-theme="light"] #console-lines li { + border-color: rgb(190 207 218 / 60%); +} +html[data-theme="light"] .profile-summary > div { + border-color: #d3e1e9; + background: #eef5f8; +} +html[data-theme="light"] .profile-summary strong { + color: #3c6e8d; +} +html[data-theme="light"] .profile-results th, +html[data-theme="light"] .profile-results td { + border-color: #d5e2e9; +} + +@media (max-width: 700px) { + .site-header .theme-select { + width: 64px; + } +} diff --git a/server/web/assets/theme.js b/server/web/assets/theme.js new file mode 100644 index 0000000..aec95ad --- /dev/null +++ b/server/web/assets/theme.js @@ -0,0 +1,42 @@ +(() => { + const key = "yawn:theme"; + const preference = matchMedia("(prefers-color-scheme: dark)"); + const saved = localStorage.getItem(key); + let mode = ["auto", "light", "dark"].includes(saved) ? saved : "auto"; + + const apply = () => { + const resolved = + mode === "auto" ? (preference.matches ? "dark" : "light") : mode; + document.documentElement.dataset.theme = resolved; + document.documentElement.dataset.themeMode = mode; + document.documentElement.style.colorScheme = resolved; + document + .querySelector('meta[name="theme-color"]') + ?.setAttribute("content", resolved === "dark" ? "#081522" : "#f7fbff"); + for (const select of document.querySelectorAll("[data-theme-select]")) + select.value = mode; + dispatchEvent( + new CustomEvent("yawn-theme-change", { detail: { mode, resolved } }), + ); + }; + + const bind = () => { + for (const select of document.querySelectorAll("[data-theme-select]")) { + select.value = mode; + select.addEventListener("change", () => { + mode = select.value; + if (mode === "auto") localStorage.removeItem(key); + else localStorage.setItem(key, mode); + apply(); + }); + } + }; + + preference.addEventListener("change", () => { + if (mode === "auto") apply(); + }); + apply(); + if (document.readyState === "loading") + addEventListener("DOMContentLoaded", bind, { once: true }); + else bind(); +})(); diff --git a/server/web/assets/yawn.d.ts b/server/web/assets/yawn.d.ts index 580aedd..29353ae 100644 --- a/server/web/assets/yawn.d.ts +++ b/server/web/assets/yawn.d.ts @@ -30,10 +30,25 @@ declare module "@yawn/core" { passes: Array<{ name: string; milliseconds: number }>; } export class YawnCore { - constructor(canvas: HTMLCanvasElement, options?: { arenaBytes?: number; debug?: boolean }); + constructor( + canvas: HTMLCanvasElement, + options?: { arenaBytes?: number; debug?: boolean }, + ); readonly ready: Promise; - createRows(options: { name: string; rows: number; stride: number; format: RowFormat }): Promise; - createRowsBatch(rows: Array<{ name: string; rows: number; stride: number; format: RowFormat }>): Promise; + createRows(options: { + name: string; + rows: number; + stride: number; + format: RowFormat; + }): Promise; + createRowsBatch( + rows: Array<{ + name: string; + rows: number; + stride: number; + format: RowFormat; + }>, + ): Promise; deleteRows(name: string): Promise; allocateObject(name: string): Promise; deleteObject(name: string, id: number): Promise; @@ -52,23 +67,44 @@ declare module "@yawn/core" { declare module "@yawn/handles" { import { SharedRows, YawnCore } from "@yawn/core"; - export interface SceneOptions { arenaBytes?: number; debug?: boolean; fps?: number; hdr?: boolean } + export interface SceneOptions { + arenaBytes?: number; + debug?: boolean; + fps?: number; + hdr?: boolean; + } export class Scene { constructor(canvas: HTMLCanvasElement, options?: SceneOptions); readonly core: YawnCore; readonly ready: Promise; readonly hdr: boolean; array(name: string): SharedRows; - ensureRows(name: string, rows: number, stride: number, format: "f32" | "u32" | "i32"): Promise; + ensureRows( + name: string, + rows: number, + stride: number, + format: "f32" | "u32" | "i32", + ): Promise; reserve(additional: { nodes?: number; materials?: number }): Promise; batchWrites(operation: () => T): T; batchGraphUpdates(operation: () => T | Promise): Promise; dispose(): void; } - export interface Vector3View { x: number; y: number; z: number; set(values: ArrayLike): void } - export interface RotorView extends Vector3View { w: number } - export interface NodeOptions { position?: ArrayLike; rotor?: ArrayLike; scale?: ArrayLike } + export interface Vector3View { + x: number; + y: number; + z: number; + set(values: ArrayLike): void; + } + export interface RotorView extends Vector3View { + w: number; + } + export interface NodeOptions { + position?: ArrayLike; + rotor?: ArrayLike; + scale?: ArrayLike; + } export class Node { constructor(scene: Scene, options?: NodeOptions); readonly scene: Scene; @@ -103,7 +139,13 @@ declare module "@yawn/handles" { dispose(): Promise; } - export type VertexKind = "positions" | "normals" | "tangents" | "uvs" | "colors" | "indices"; + export type VertexKind = + | "positions" + | "normals" + | "tangents" + | "uvs" + | "colors" + | "indices"; export interface MeshOptions extends NodeOptions { geometryId?: number; material?: PBRMaterial; @@ -119,10 +161,55 @@ declare module "@yawn/handles" { material: PBRMaterial; clone(options?: Omit): Mesh; setVertexData(kind: VertexKind, data: ArrayLike): Promise; - setMaterialForFaces(material: PBRMaterial, faces: number | number[]): Promise; + setMaterialForFaces( + material: PBRMaterial, + faces: number | number[], + ): Promise; } - export interface CameraOptions extends NodeOptions { fov?: number; near?: number; far?: number; aspect?: number } + export interface CubeOptions + extends Omit { + size?: number; + width?: number; + height?: number; + depth?: number; + } + export class Cube extends Mesh { + constructor(scene: Scene, options?: CubeOptions); + } + export interface IcosphereOptions + extends Omit { + radius?: number; + subdivisions?: number; + } + export class Icosphere extends Mesh { + constructor(scene: Scene, options?: IcosphereOptions); + } + export interface PlaneOptions + extends Omit { + size?: number; + width?: number; + depth?: number; + } + export class Plane extends Mesh { + constructor(scene: Scene, options?: PlaneOptions); + } + export interface SphereOptions + extends Omit { + radius?: number; + segments?: number; + rings?: number; + } + export class Sphere extends Mesh { + constructor(scene: Scene, options?: SphereOptions); + } + + export interface CameraOptions extends NodeOptions { + fov?: number; + near?: number; + far?: number; + aspect?: number; + } export class Camera extends Node { fov: number; near: number; @@ -130,30 +217,106 @@ declare module "@yawn/handles" { aspect: number; } export class ArcRotateCamera extends Camera { - constructor(scene: Scene, options?: CameraOptions & { target?: Node; alpha?: number; beta?: number; radius?: number; controls?: { element: HTMLElement; pointer?: boolean; controller?: boolean } }); + constructor( + scene: Scene, + options?: CameraOptions & { + target?: Node; + alpha?: number; + beta?: number; + radius?: number; + controls?: { + element: HTMLElement; + pointer?: boolean; + controller?: boolean; + }; + }, + ); alpha: number; beta: number; radius: number; + detachControls(): this; } export class FreeCamera extends Camera { - constructor(scene: Scene, options?: CameraOptions & { controls?: { element: HTMLElement; keyboard?: boolean; pointer?: boolean; controller?: boolean; speed?: number } }); + constructor( + scene: Scene, + options?: CameraOptions & { + controls?: { + element: HTMLElement; + keyboard?: boolean; + pointer?: boolean; + controller?: boolean; + speed?: number; + }; + }, + ); } export class FollowCamera extends Camera { - constructor(scene: Scene, options?: CameraOptions & { target?: Node; distance?: number; height?: number; smoothing?: number }); + constructor( + scene: Scene, + options?: CameraOptions & { + target?: Node; + distance?: number; + height?: number; + smoothing?: number; + }, + ); } - export interface LightOptions extends NodeOptions { color?: ArrayLike; intensity?: number } - export class PointLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number }); intensity: number; range: number } - export class AmbientLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } - export class DirectionalLight extends Node { constructor(scene: Scene, options?: LightOptions); intensity: number } - export class SpotLight extends Node { constructor(scene: Scene, options?: LightOptions & { range?: number; innerAngle?: number; outerAngle?: number }) } - export class RectAreaLight extends Node { constructor(scene: Scene, options?: LightOptions & { width?: number; height?: number }) } + export interface LightOptions extends NodeOptions { + color?: ArrayLike; + intensity?: number; + } + export class PointLight extends Node { + constructor(scene: Scene, options?: LightOptions & { range?: number }); + intensity: number; + range: number; + } + export class AmbientLight extends Node { + constructor(scene: Scene, options?: LightOptions); + intensity: number; + } + export class DirectionalLight extends Node { + constructor(scene: Scene, options?: LightOptions); + intensity: number; + } + export class SpotLight extends Node { + constructor( + scene: Scene, + options?: LightOptions & { + range?: number; + innerAngle?: number; + outerAngle?: number; + }, + ); + } + export class RectAreaLight extends Node { + constructor( + scene: Scene, + options?: LightOptions & { width?: number; height?: number }, + ); + } - export class ComputePass { constructor(options: Record); readonly id: string } - export class FXAA { constructor(scene: Scene, options?: Record); readonly ready: Promise; dispose(): Promise } - export class ColorGrading extends FXAA { constructor(scene: Scene, options?: { toneMap?: "aces" | "reinhard" | "linear"; amount?: number }) } - export class DynamicExposure extends FXAA { constructor(scene: Scene, options?: { exposure?: number }) } - export class SSAO extends FXAA { constructor(scene: Scene, options?: { amount?: number }) } + export class ComputePass { + constructor(options: Record); + readonly id: string; + } + export class FXAA { + constructor(scene: Scene, options?: Record); + readonly ready: Promise; + dispose(): Promise; + } + export class ColorGrading extends FXAA { + constructor( + scene: Scene, + options?: { toneMap?: "aces" | "reinhard" | "linear"; amount?: number }, + ); + } + export class DynamicExposure extends FXAA { + constructor(scene: Scene, options?: { exposure?: number }); + } + export class SSAO extends FXAA { + constructor(scene: Scene, options?: { amount?: number }); + } export function importGltf(scene: Scene, url: string | URL): Promise; } diff --git a/server/web/docs.html b/server/web/docs.html index 8eca51b..a9f68d4 100644 --- a/server/web/docs.html +++ b/server/web/docs.html @@ -4,26 +4,43 @@ - + Learn Yawn — Documentation + +
@@ -54,7 +71,12 @@ Craft your own API Deployment
- ▶
Try as you learnOpen the playground
+ ▶ +
+ Try as you learnOpen the playground +
@@ -63,13 +85,20 @@
Start here · 5 minute read

Build your first Yawn scene

- This guide starts from an empty HTML file and ends with a WebGPU triangle you can - move, shade, and inspect. You do not need engine experience. Every new term is - explained where it first appears. + This guide starts from an empty HTML file and ends with a WebGPU + triangle you can move, shade, and inspect. You do not need engine + experience. Every new term is explained where it first appears.

i -
New to programming?

Type the examples exactly as shown. Words in code style are names the computer expects; the prose around them explains why.

+
+ New to programming? +

+ Type the examples exactly as shown. Words in + code style are names the computer expects; the + prose around them explains why. +

+
@@ -77,34 +106,55 @@
01 Installation

Load Handles from Yawn’s CDN

- You do not build or host Yawn yourself. An import map gives the CDN module a - short name that the rest of this tutorial can use. Put this inside your page’s + You do not build or host Yawn yourself. An + import map gives the CDN module a short name that the + rest of this tutorial can use. Put this inside your page’s <head>:

-
index.html
+
+ index.html +
<script type="importmap">
 {
   "imports": {
+    "@yawn/core": "https://yawn.heaust.org/pkg/core.js",
     "@yawn/handles": "https://yawn.heaust.org/pkg/handles.js"
   }
 }
 </script>

- That URL serves a ready-to-use ES module. Its render worker, import worker, picking - worker, and WebAssembly module all continue loading from Yawn’s CDN automatically. + Core and Handles stay separate: Handles uses the mapped Core + module instead of containing its own copy. Their render worker, + import worker, picking worker, and WebAssembly module all continue + loading from Yawn’s CDN automatically.

Make a canvas

-

A canvas is the rectangle where WebGPU will draw. Add one to the page body:

+

+ A canvas is the rectangle where WebGPU will draw. Add + one to the page body: +

-
index.html
+
+ index.html +
<canvas id="view" width="1280" height="720"></canvas>
 <script type="module" src="/app.js"></script>
! -
Your page needs isolation headers

Your own web host must send COOP: same-origin and COEP: require-corp so SharedArrayBuffer is available. The CDN already sends the matching CORS and resource-policy headers for Yawn’s files.

+
+ Your page needs isolation headers +

+ Your own web host must send COOP: same-origin and + COEP: require-corp so SharedArrayBuffer is + available. The CDN already sends the matching CORS and + resource-policy headers for Yawn’s files. +

+
@@ -113,12 +163,16 @@

Scene → material → mesh

A scene owns the shared data and render graph. A - material describes the surface. A mesh supplies points - and tells Yawn which order to connect them. + material describes the surface. A + mesh supplies points and tells Yawn which order + to connect them.

-
app.js
-
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
+              
+ app.js +
+
import "@yawn/core";
+import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
 
 const canvas = document.querySelector("#view");
 const scene = new Scene(canvas, { hdr: true, fps: 60 });
@@ -141,23 +195,48 @@ const triangle = new Mesh(scene, {
 await triangle.ready;
-
new

Creates one object. Here that is a scene, material, or mesh handle.

-
await …ready

Waits for setup to finish before the next object depends on it.

-
[x, y, z]

One point in 3D space: horizontal, vertical, and depth.

+
+ new +

+ Creates one object. Here that is a scene, material, or mesh + handle. +

+
+
+ await …ready +

+ Waits for setup to finish before the next object depends on + it. +

+
+
+ [x, y, z] +

One point in 3D space: horizontal, vertical, and depth.

+
- ▶
Run this exampleIt is already loaded in the playground
Open →
+ ▶ +
+ Run this exampleIt is already loaded in the playground +
+ Open →
03 Move around

Add a camera you can orbit

- The starter triangle is already in clip space, so it is visible without a camera. - For a 3D world, add an ArcRotateCamera. Drag to orbit and use the wheel to + The starter triangle is already in clip space, so it is visible + without a camera. For a 3D world, add an + ArcRotateCamera. Drag to orbit and use the wheel to zoom.

-
app.ts
+
+ app.ts +
import { ArcRotateCamera } from "@yawn/handles";
 
 const camera = new ArcRotateCamera(scene, {
@@ -175,12 +254,16 @@ await camera.ready;
04 Build a world

Clone geometry, not work

- Every Mesh is an instance. Calling clone() shares its geometry - and creates only a new transform and mesh slot. If one clone later changes its vertex + Every Mesh is an instance. Calling + clone() shares its geometry and creates only a new + transform and mesh slot. If one clone later changes its vertex data, Yawn makes that geometry unique automatically.

-
instances.ts
+
+ instances.ts +
for (let x = -4; x <= 4; x++) {
   const copy = triangle.clone({ position: [x * 0.35, 0, 0] });
   await copy.ready;
@@ -191,11 +274,14 @@ await camera.ready;

Materials and lights are ordinary handles

- Change a material after it is ready and Yawn writes the new value directly into its - shared row. Lights use the same pattern. + Change a material after it is ready and Yawn writes the new value + directly into its shared row. Lights use the same pattern.

-
lighting.ts
+
+ lighting.ts +
import { AmbientLight, PointLight } from "@yawn/handles";
 
 const key = new PointLight(scene, {
@@ -217,11 +303,15 @@ sky.roughness = 0.5; // one shared-memory write

Bring in a glTF model

- importGltf fetches and parses .gltf or .glb data in - a worker, then creates ordinary Yawn meshes and materials. + importGltf fetches and parses .gltf or + .glb data in a worker, then creates ordinary Yawn + meshes and materials.

-
model.ts
+
+ model.ts +
import { importGltf } from "@yawn/handles";
 
 const meshes = await importGltf(scene, "/models/robot.glb");
@@ -231,9 +321,15 @@ meshes[0].position.y = 0.5;

Add effects without leaving the scene API

-

Effect handles add passes to the same render graph. Batch related additions to rebuild that graph once.

+

+ Effect handles add passes to the same render graph. Batch related + additions to rebuild that graph once. +

-
effects.ts
+
+ effects.ts +
import { ColorGrading, FXAA } from "@yawn/handles";
 
 await scene.batchGraphUpdates(async () => {
@@ -248,27 +344,40 @@ await scene.batchGraphUpdates(async () => {
             
05 Understand Yawn

Setup is messages. Motion is memory.

- Expensive structural changes—creating rows, allocating IDs, or replacing a render - graph—go to the render worker as messages. Values that already exist—positions, - colors, camera matrices, light strengths—change in shared memory. + Expensive structural changes—creating rows, allocating IDs, or + replacing a render graph—go to the render worker as messages. + Values that already exist—positions, colors, camera matrices, + light strengths—change in shared memory.

-
Infrequent controlcreate · allocate · compile · switch
+
+ Infrequent controlcreate · allocate · compile · switch +
→ -
Shared rowstransform · shade · animate
+
+ Shared rowstransform · shade · animate +
→ -
Rust/WASM coreschedule · upload · render
+
+ Rust/WASM coreschedule · upload · render +

Write your own hot data

- Handles are views over named rows. Your application can add rows too. Each row is - aligned for predictable CPU and GPU use. + Handles are views over named rows. Your application can add rows + too. Each row is aligned for predictable CPU and GPU use.

-
simulation.ts
+
+ simulation.ts +
const velocity = await scene.ensureRows(
   "app.velocity", 10_000, 16, "f32"
 );
@@ -276,31 +385,48 @@ await scene.batchGraphUpdates(async () => {
 velocity.row(42).set([1, 0, 0, 0]);

- That final line changes four floats without cloning an object or posting a worker - message. The scene marks shared data dirty so the next frame sees it. + That final line changes four floats without cloning an object or + posting a worker message. The scene marks shared data dirty so the + next frame sees it.

Measure the passes the GPU actually ran

- Open Profile in the playground to enable timestamp queries. Yawn - reports the physical pass names and GPU milliseconds without serializing the render - queue. Support depends on the browser and adapter. + Open Profile in the playground to enable + timestamp queries. Yawn reports the physical pass names and GPU + milliseconds without serializing the render queue. Support depends + on the browser and adapter.

-
⌁
Profile a real scene

The playground’s profiler uses core.onProfile() and core.setProfiler(true)—the same public APIs available to your app.

+
+ ⌁ +
+ Profile a real scene +

+ The playground’s profiler uses + core.onProfile() and + core.setProfiler(true)—the same public APIs + available to your app. +

+
+
06 Deep dive

Outgrow handles without outgrowing Yawn

- @yawn/core has no scene, mesh, material, camera, or built-in shader. It - owns a shared arena and a render-graph runtime. Handles are one replaceable frontend - that builds on those two primitives. + @yawn/core has no scene, mesh, material, camera, or + built-in shader. It owns a shared arena and a render-graph + runtime. Handles are one replaceable frontend that builds on those + two primitives.

-
core.ts
+
+ core.ts +
import { YawnCore } from "https://yawn.heaust.org/pkg/core.js";
 
 const core = new YawnCore(canvas, {
@@ -316,20 +442,25 @@ const particles = await core.createRows({
 });

- From here, your frontend supplies WGSL, resources, pipelines, and a pass DAG. Core - compiles that description into an up-front loadout, aliases compatible transient - textures, and records render work. + From here, your frontend supplies WGSL, resources, pipelines, and + a pass DAG. Core compiles that description into an up-front + loadout, aliases compatible transient textures, and records render + work.

Craft an API for your problem

- A custom handle can be as small as an ID plus getters and setters into shared rows. - Keep domain policy in your code and send only structural changes to core. + A custom handle can be as small as an ID plus getters and setters + into shared rows. Keep domain policy in your code and send only + structural changes to core.

-
Particle.ts
+
+ Particle.ts +
class Particle {
   constructor(
     readonly id: number,
@@ -346,11 +477,50 @@ const particles = await core.createRows({
           

Deployment checklist

    -
  • 1
    Serve over HTTPS

    WebGPU and cross-origin isolation require a secure browser context outside local development.

  • -
  • 2
    Keep the isolation headers

    Send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

  • -
  • 3
    Allow Yawn’s CDN

    The imported module keeps every worker and WASM request on yawn.heaust.org. If you use a Content Security Policy, allow that origin and blob: workers.

  • +
  • + 1 +
    + Serve over HTTPS +

    + WebGPU and cross-origin isolation require a secure browser + context outside local development. +

    +
    +
  • +
  • + 2 +
    + Keep the isolation headers +

    + Send + Cross-Origin-Opener-Policy: same-origin and + Cross-Origin-Embedder-Policy: require-corp. +

    +
    +
  • +
  • + 3 +
    + Allow Yawn’s CDN +

    + The imported module keeps every worker and WASM request on + yawn.heaust.org. If you use a Content Security + Policy, allow that origin and blob: workers. +

    +
    +
-
Next step

Make the starter scene yours.

Open the playground, choose a snippet, and save a revision you can share.

Open playground →
+
+
+ Next step +

Make the starter scene yours.

+

+ Open the playground, choose a snippet, and save a revision you + can share. +

+
+ Open playground → +
diff --git a/server/web/home.html b/server/web/home.html index bb7abc7..1abefa3 100644 --- a/server/web/home.html +++ b/server/web/home.html @@ -9,13 +9,17 @@ content="Yawn is a WebGPU engine that starts with friendly scene handles and scales down to a programmable Rust/WASM render core." /> Yawn — a WebGPU engine that grows with you + + @@ -32,17 +41,24 @@
WebGPU without the ceremony

Start with a scene.
Scale into an engine.

- Yawn gives you approachable TypeScript handles on day one, direct shared-memory - performance when you need it, and a programmable render core when your ideas outgrow - conventions. + Yawn gives you approachable TypeScript handles on day one, direct + shared-memory performance when you need it, and a programmable + render core when your ideas outgrow conventions.

- - One CDN import · No framework required · Rust/WASM core + + Separate Core + Handles modules · No framework required · Rust/WASM + core
@@ -78,7 +94,9 @@
Oneshared arena
Zeromessages for hot state
-
Anyrender graph you can describe
+
+ Anyrender graph you can describe +
SmallRust/WASM foundation
@@ -87,8 +105,9 @@
Progressive by design

Use exactly as much engine as you need.

- You do not have to choose between an easy API and a serious foundation. Yawn keeps - those layers separate, so learning more never means starting over. + You do not have to choose between an easy API and a serious + foundation. Yawn keeps those layers separate, so learning more never + means starting over.

@@ -97,8 +116,9 @@

Build with handles

- Start with familiar scenes, meshes, materials, lights, cameras, glTF imports, and - post effects. Constructors are explicit and every async boundary is visible. + Start with familiar scenes, meshes, materials, lights, cameras, + glTF imports, and post effects. Constructors are explicit and + every async boundary is visible.

Make your first scene → @@ -107,18 +127,22 @@

Move through shared data

- Positions, cameras, lights, materials, and your own application rows live in one - SharedArrayBuffer. Hot updates become direct typed-array writes. + Positions, cameras, lights, materials, and your own application + rows live in one SharedArrayBuffer. Hot updates become direct + typed-array writes.

- Understand the fast path → + Understand the fast path →
03

Author the whole graph

- Use core directly when you need custom resources, WGSL pipelines, compute, pass - dependencies, transient aliasing, and up-front GPU loadouts. + Use core directly when you need custom resources, WGSL pipelines, + compute, pass dependencies, transient aliasing, and up-front GPU + loadouts.

Go beneath handles →
@@ -128,18 +152,30 @@
-
A quiet hot path
+
+ A quiet hot path +

Your frame should move data, not negotiate it.

- Yawn pays setup costs when your scene or graph changes. During play, ordinary updates - stay in shared rows that JavaScript and the render worker can both see. + Yawn pays setup costs when your scene or graph changes. During + play, ordinary updates stay in shared rows that JavaScript and the + render worker can both see.

    -
  • ✓ Structure-of-arrays layout with aligned rows
  • -
  • ✓ Render graphs compiled before they become active
  • -
  • ✓ Optional timestamp profiling for physical GPU passes
  • +
  • + ✓ Structure-of-arrays layout with aligned rows +
  • +
  • + ✓ Render graphs compiled before they become active +
  • +
  • + ✓ Optional timestamp profiling for physical GPU + passes +
- See how the pieces connect → + See how the pieces connect →
Your application
@@ -148,11 +184,18 @@
-
SharedArrayBufferaligned SOA arena
-
+
+ SharedArrayBufferaligned SOA arena +
+
+ +
-
Rust / WASMRender graph core
+
+ Rust / WASMRender graph core +
WebGPU
@@ -167,17 +210,30 @@
  1. 1 -

    Create a scene

    Give Yawn a canvas and await one clear readiness promise.

    +
    +

    Create a scene

    +

    Give Yawn a canvas and await one clear readiness promise.

    +
    new Scene(canvas)
  2. 2 -

    Add what you can see

    Materials and meshes own typed handles, not hidden global state.

    +
    +

    Add what you can see

    +

    + Materials and meshes own typed handles, not hidden global state. +

    +
    new Mesh(scene, options)
  3. 3 -

    Change values directly

    After setup, ordinary property updates write into shared rows.

    +
    +

    Change values directly

    +

    + After setup, ordinary property updates write into shared rows. +

    +
    mesh.position.x += 0.1
@@ -185,21 +241,32 @@
-
The best way to understand it
+
+ The best way to understand it +

Change the code. Watch the passes.

- The playground includes TypeScript intelligence, reusable snippets, live output, - shareable SQLite-backed revisions, and the engine’s real GPU profiler. + The playground includes TypeScript intelligence, reusable snippets, + live output, shareable SQLite-backed revisions, and the engine’s + real GPU profiler.

- Launch playground ↗ + Launch playground ↗
diff --git a/server/web/playground.html b/server/web/playground.html index 1571cb8..c2cd484 100644 --- a/server/web/playground.html +++ b/server/web/playground.html @@ -4,9 +4,22 @@ - + Yawn Playground + + + @@ -14,11 +27,18 @@
- Yawn + Yawn
- + Draft
@@ -26,6 +46,11 @@ Docs Source + @@ -39,9 +64,15 @@
- - - + + +
@@ -50,17 +81,29 @@ Starting language service…
- - + +
- + +
+
+
Loading TypeScript editor…
-
Loading TypeScript editor…
Ln 1, Col 1 Spaces: 2 @@ -70,7 +113,13 @@
- +
@@ -95,22 +144,73 @@
- - - + + +
-
-
›_ Output from log() appears here.
+
+
+ ›_ Output from log() appears here. +
    -
    +
    ⌁ -
    Waiting for GPU timestampsRun the scene to inspect its physical passes.
    +
    + Waiting for GPU timestampsRun the scene to inspect its physical passes. +
    @@ -120,16 +220,66 @@
    -
    Code library

    Snippets

    Insert a focused building block at your cursor.

    - +
    + Code library +

    Snippets

    +

    Insert a focused building block at your cursor.

    +
    + +
    + -
    - - - - - + + + + +