Files
Ampandheaust d279ec4123 Add clay pieces, masking, and topology editing to sculpt
Add pastel presets, piece selection and colors, mask brushes, subdivision and conservative decimation with bounded worker operations and mixed undo history.

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

258 lines
8.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, @location(1) mask:f32, @location(2) material:vec3f }
@vertex fn vertex(@location(0) p:vec3f, @location(1) mask:f32, @location(2) n:vec3f, @location(3) material:vec4f)->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));o.mask=mask;o.material=material.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 base=mix(o.material,o.material*0.22,clamp(o.mask,0.0,1.0));
let color=base*(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: Math.max(1, mesh.positions.length / 3),
stride: 32,
format: "f32",
},
{
name: "sculptIndices",
rows: Math.max(1, 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: "float32", offset: 12, shaderLocation: 1 },
{ format: "float32x3", offset: 16, shaderLocation: 2 },
{ format: "unorm8x4", offset: 28, shaderLocation: 3 },
],
},
],
},
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,
bytes = new Uint8Array(v.buffer, v.byteOffset, v.byteLength),
pieces = this.mesh.pieces ?? [];
let pieceIndex = pieces.findIndex(
(piece) => first < piece.vertexStart + piece.vertexCount,
);
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 + 3] = this.mesh.masks?.[i] ?? 0;
v[b + 4] = n[a];
v[b + 5] = n[a + 1];
v[b + 6] = n[a + 2];
while (
pieceIndex >= 0 &&
pieceIndex < pieces.length - 1 &&
i >= pieces[pieceIndex].vertexStart + pieces[pieceIndex].vertexCount
)
pieceIndex++;
const piece = pieces[pieceIndex];
const color = piece?.color ?? [0.5, 0.3, 0.2];
for (let k = 0; k < 3; k++)
bytes[b * 4 + 28 + k] = Math.round(color[k] * 255);
bytes[b * 4 + 31] = 255;
}
}
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();
}
}