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>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// Explicit browser benchmark; never imported by the application.
|
||||
// await (await import('/assets/sculpt/latency-check.js')).check()
|
||||
import { Input } from "./input.js";
|
||||
import { Renderer } from "./renderer.js";
|
||||
import { StrokeClient } from "./stroke-client.js";
|
||||
import { Sculpt } from "./engine.js";
|
||||
|
||||
export async function check() {
|
||||
const viewport = document.getElementById("viewport");
|
||||
const move = Input.prototype.move,
|
||||
publish = Renderer.prototype.publish,
|
||||
message = StrokeClient.prototype.message,
|
||||
dab = Sculpt.prototype.dab;
|
||||
const capture = viewport.setPointerCapture;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const frame = () => new Promise(requestAnimationFrame);
|
||||
const wait = async (predicate) => {
|
||||
const start = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - start > 15000)
|
||||
throw new Error("Latency check timed out");
|
||||
await sleep(10);
|
||||
}
|
||||
};
|
||||
let input,
|
||||
durations = [],
|
||||
uploads = [],
|
||||
packets = [],
|
||||
mainDabs = 0,
|
||||
canceled = false;
|
||||
Input.prototype.move = function (event) {
|
||||
input = this;
|
||||
const start = performance.now();
|
||||
const result = move.call(this, event);
|
||||
durations.push(performance.now() - start);
|
||||
return result;
|
||||
};
|
||||
Sculpt.prototype.dab = function (...args) {
|
||||
mainDabs++;
|
||||
return dab.apply(this, args);
|
||||
};
|
||||
StrokeClient.prototype.message = function (data) {
|
||||
const start = performance.now();
|
||||
const result = message.call(this, data);
|
||||
if (data.type === "geometry") packets.push(performance.now() - start);
|
||||
return result;
|
||||
};
|
||||
Renderer.prototype.publish = async function (...args) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await publish.apply(this, args);
|
||||
} finally {
|
||||
uploads.push(performance.now() - start);
|
||||
}
|
||||
};
|
||||
viewport.setPointerCapture = () => {};
|
||||
const pointer = (type, x, y, pressure = 0.8) => {
|
||||
const r = viewport.getBoundingClientRect();
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
pointerId: 7,
|
||||
pointerType: "pen",
|
||||
clientX: r.left + r.width / 2 + x,
|
||||
clientY: r.top + r.height / 2 + y,
|
||||
pressure,
|
||||
button: 0,
|
||||
buttons: type === "pointerup" ? 0 : 1,
|
||||
});
|
||||
};
|
||||
const send = (event) => viewport.querySelector("canvas").dispatchEvent(event);
|
||||
const percentile = (values, p) =>
|
||||
[...values].sort((a, b) => a - b)[
|
||||
Math.min(values.length - 1, Math.floor(values.length * p))
|
||||
] ?? 0;
|
||||
const results = [];
|
||||
try {
|
||||
for (const brush of ["draw", "grab"])
|
||||
for (const perFrame of [1, 4, 16]) {
|
||||
await wait(
|
||||
() =>
|
||||
document.getElementById("loading").hidden &&
|
||||
!document.getElementById("new").disabled,
|
||||
);
|
||||
const old = viewport.querySelector("canvas");
|
||||
document.getElementById("new").click();
|
||||
document.getElementById("density").value = "5";
|
||||
document.getElementById("new-dialog").close("create");
|
||||
await wait(
|
||||
() =>
|
||||
old !== viewport.querySelector("canvas") &&
|
||||
document.getElementById("loading").hidden,
|
||||
);
|
||||
document.querySelector(`[data-brush="${brush}"]`).click();
|
||||
await sleep(300);
|
||||
durations = [];
|
||||
uploads = [];
|
||||
packets = [];
|
||||
mainDabs = 0;
|
||||
canceled = false;
|
||||
send(pointer("pointerdown", -80, -30));
|
||||
for (let f = 0; f < 60; f++) {
|
||||
await frame();
|
||||
const samples = Array.from({ length: perFrame }, (_, i) => {
|
||||
const t = (f + (i + 1) / perFrame) / 60;
|
||||
return pointer(
|
||||
"pointermove",
|
||||
-80 + 160 * t,
|
||||
-30 + 30 * Math.sin(t * Math.PI),
|
||||
0.3 + 0.6 * t,
|
||||
);
|
||||
});
|
||||
const event = samples.at(-1);
|
||||
Object.defineProperty(event, "getCoalescedEvents", {
|
||||
value: () => samples,
|
||||
});
|
||||
send(event);
|
||||
if (input && !input.busy) canceled = true;
|
||||
}
|
||||
const release = performance.now();
|
||||
send(pointer("pointerup", 80, -30, 0));
|
||||
await wait(() => !document.getElementById("save").disabled);
|
||||
const releaseMs = performance.now() - release;
|
||||
const undoEntries = input.host.engine().undoStack.length;
|
||||
canceled ||= undoEntries !== 1;
|
||||
if (canceled || mainDabs || input.queue !== undefined)
|
||||
throw new Error("Worker input regression");
|
||||
results.push({
|
||||
brush,
|
||||
samplesPerFrame: perFrame,
|
||||
sharedInputBytes: input.host.processor().mailbox.words.byteLength,
|
||||
mainDabs,
|
||||
canceled,
|
||||
releaseMs,
|
||||
mainInputP95: percentile(durations, 0.95),
|
||||
mainPacketP95: percentile(packets, 0.95),
|
||||
uploadP95: percentile(uploads, 0.95),
|
||||
undoEntries,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
Input.prototype.move = move;
|
||||
StrokeClient.prototype.message = message;
|
||||
Sculpt.prototype.dab = dab;
|
||||
Renderer.prototype.publish = publish;
|
||||
viewport.setPointerCapture = capture;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user