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>
258 lines
8.3 KiB
JavaScript
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();
|
|
}
|
|
}
|