Files
yawn/server/web/assets/sculpt/renderer.js
T
Ampandheaust 347b676cf1 Add core-powered web sculpting studio with tablet controls
Add pressure-aware worker sculpting, bounded shared input, project files and history, and compact icon navigation with an XYZ orbit gizmo.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b4b4-202c-73f1-984d-9a55c88f9c83
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-09-28 08:53:50 +00:00

238 lines
7.3 KiB
JavaScript

import { YawnCore } from "@yawn/core";
import { cameraBasis } from "./math.js";
function wire(v) {
if (Array.isArray(v)) return `(array ${v.map(wire).join(" ")})`;
if (v && typeof v === "object")
return `(object ${Object.entries(v)
.map(([k, x]) => `(field ${JSON.stringify(k)} ${wire(x)})`)
.join(" ")})`;
return JSON.stringify(v);
}
const shader = `
struct Camera { right:vec4f, up:vec4f, forward:vec4f, center:vec4f, color:vec4f }
@group(0) @binding(0) var<uniform> camera:Camera;
struct Out { @builtin(position) position:vec4f, @location(0) normal:vec3f }
@vertex fn vertex(@location(0) p:vec3f, @location(1) n:vec3f)->Out {
let d=p-camera.center.xyz;
var o:Out;
o.position=vec4f(dot(d,camera.right.xyz)/camera.right.w,dot(d,camera.up.xyz)/camera.up.w,0.5-dot(d,camera.forward.xyz)/32.0,1.0);
o.normal=vec3f(dot(n,camera.right.xyz),dot(n,camera.up.xyz),dot(n,camera.forward.xyz));return o;
}
@fragment fn fragment(o:Out)->@location(0) vec4f {
let n=normalize(o.normal);
let key=max(dot(n,normalize(vec3f(-0.5,0.8,1.0))),0.0);
let fill=max(dot(n,normalize(vec3f(0.8,0.0,0.4))),0.0);
let rim=pow(1.0-max(n.z,0.0),3.0);
let spec=pow(max(dot(n,normalize(vec3f(-0.3,0.45,1.0))),0.0),48.0);
let color=camera.color.xyz*(0.24+key*0.65+fill*0.16)+vec3f(0.12,0.14,0.16)*rim+vec3f(0.12)*spec;
return vec4f(pow(color,vec3f(1.0/2.2)),1.0);
}`;
export class Renderer {
static async create(canvas, mesh, camera, quality = "balanced") {
const rect = canvas.getBoundingClientRect();
const pixels =
quality === "economy" ? 600000 : quality === "detail" ? 2000000 : 1000000;
const scale = Math.min(
devicePixelRatio,
quality === "economy" ? 1 : quality === "detail" ? 2 : 1.5,
Math.sqrt(pixels / (rect.width * rect.height)),
);
canvas.width = Math.max(1, Math.round(rect.width * scale));
canvas.height = Math.max(1, Math.round(rect.height * scale));
const r = new Renderer();
r.canvas = canvas;
r.mesh = mesh;
const bytes =
(mesh.positions.length / 3) * 32 +
Math.ceil(mesh.indices.length / 4) * 16 +
80 +
64 +
256;
if (bytes > 8 * 1024 * 1024)
throw new Error("Mesh exceeds the render arena");
r.core = new YawnCore(canvas, { arenaBytes: 8 * 1024 * 1024 });
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
r.dispose();
}, 20000);
try {
await r.core.ready;
[r.vertices, r.indices, r.camera, r.sample] =
await r.core.createRowsBatch([
{
name: "sculptVertices",
rows: mesh.positions.length / 3,
stride: 32,
format: "f32",
},
{
name: "sculptIndices",
rows: Math.ceil(mesh.indices.length / 4),
stride: 16,
format: "u32",
},
{ name: "sculptCamera", rows: 1, stride: 80, format: "f32" },
{ name: "sculptSample", rows: 1, stride: 64, format: "i32" },
]);
r.sample.view.fill(0);
r.indices.view.set(mesh.indices);
r.writeVertices(0, mesh.positions.length / 3);
r.writeCamera(camera);
const graph = {
id: "sculpt",
resources: {
buffers: [
{
id: "vertices",
array: "sculptVertices",
usage: ["vertex"],
sync: "manual",
},
{
id: "indices",
array: "sculptIndices",
usage: ["index"],
sync: "manual",
},
{
id: "camera",
array: "sculptCamera",
usage: ["uniform"],
sync: "manual",
},
],
textures: [
{
id: "depth",
format: "depth24plus",
size: ["canvas", "canvas"],
usage: ["render"],
},
],
},
pipelines: {
render: [
{
id: "clay",
code: shader,
vertex: {
buffers: [
{
arrayStride: 32,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 0 },
{ format: "float32x3", offset: 16, shaderLocation: 1 },
],
},
],
},
fragment: { targets: [{ format: "canvas" }] },
primitive: {
topology: "triangle-list",
frontFace: "ccw",
cullMode: "back",
conservative: false,
},
depthStencil: {
format: "depth24plus",
depth_write_enabled: true,
depth_compare: "less",
},
},
],
},
passes: [
{
id: "sculpt",
type: "render",
pipeline: "clay",
bindings: [{ binding: 0, resource: "camera" }],
color: [{ resource: "canvas", clear: [0.12, 0.14, 0.17, 1] }],
depth: { resource: "depth" },
vertexBuffers: [{ resource: "vertices" }],
indexBuffer: { resource: "indices" },
draw: { indices: mesh.indices.length, instances: 1 },
},
],
};
await r.core.switchLoadout(
await r.core.compileGraph(`(yawn-graph 1 ${wire(graph)})`),
);
await r.core.setFps(quality === "economy" ? 30 : 60);
await r.core.play();
return r;
} catch (error) {
r.dispose();
if (timedOut) throw new Error("Renderer initialization timed out");
throw error;
} finally {
clearTimeout(timeout);
}
}
writeVertices(first, count) {
const p = this.mesh.positions,
n = this.mesh.normals,
v = this.vertices.view;
for (let i = first; i < first + count; i++) {
const a = i * 3,
b = i * 8;
v[b] = p[a];
v[b + 1] = p[a + 1];
v[b + 2] = p[a + 2];
v[b + 4] = n[a];
v[b + 5] = n[a + 1];
v[b + 6] = n[a + 2];
}
}
writeCamera(camera) {
const { right, up, forward } = cameraBasis(camera.yaw, camera.pitch);
const aspect = this.canvas.width / this.canvas.height;
this.camera.view.set([
...right,
camera.zoom * aspect,
...up,
camera.zoom,
...forward,
0,
...camera.target,
0,
...camera.color,
0,
]);
}
// One acknowledged publication at a time: shared staging bytes are immutable in flight.
async publish(engine, camera) {
if (this.busy || this.disposed) return false;
this.busy = true;
const timeout = setTimeout(() => this.dispose(), 10000);
try {
let first = Infinity,
last = -1;
for (const v of engine.dirty) {
first = Math.min(first, v);
last = Math.max(last, v);
}
engine.dirty.clear();
if (last >= first) {
this.writeVertices(first, last - first + 1);
await this.core.syncRows("sculptVertices", first, last - first + 1);
}
if (this.disposed) return false;
this.writeCamera(camera);
await this.core.syncRows("sculptCamera", 0, 1);
return true;
} finally {
clearTimeout(timeout);
this.busy = false;
}
}
dispose() {
this.disposed = true;
this.processor?.dispose();
this.core?.dispose();
}
}