feat(visual): implement the slice 4f execution, automation, and post-effects
Visual automation in both of its scopes — exhibit-scope tracks measured from activation and system-scope tracks measured from an instance's own instantiation, so a track written against spawn-relative time behaves the same whether the spawn happens at four seconds or at four minutes — together with the spawned-system lifecycle, the camera, and the seven post-effects of 19.4. Automation on a missing target object now raises ERR_INVALID_REFERENCE rather than an unguarded TypeError, per 19.1. Post-effect channel math is clamped explicitly before assignment and the device blur radius is capped against a pixel budget, so a large projected radius cannot turn a legal exhibit into a frame-long stall. Trail fade tapers per segment from the item's own head opacity to the tail factor, which is what 18.8 describes; the previous flat alpha ignored the item's ramped opacity entirely. The canvas backend consumes the per-object buffer grants the engine allocates, rasterizing a buffered node once and compositing it once under its own blend and alpha, rather than compositing fill, stroke, glow, and filter separately and double-blending the overlap. Glow is drawn as a halo around the result rather than painted over it, and text now gets the glow branch it was silently missing. Traces 19.7.3 and 19.7.4 are implemented as specified rather than approximated: the two-spawn four-second offset assertion, and the four negative target cases that separate ERR_INVALID_REFERENCE from ERR_UNSUPPORTED_TARGET. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
This commit is contained in:
@@ -33,6 +33,10 @@ export class XZBTApplication {
|
||||
onUpdate: (engine) => {
|
||||
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
|
||||
},
|
||||
onTick: (step) => {
|
||||
if (this.activation.current?.record.id !== record.id) return;
|
||||
this.visual.advance(step);
|
||||
},
|
||||
// The renderer runs at display rate; the simulation stays on the fixed
|
||||
// logical tick (9.1), so a frame draws the most recent tick's state.
|
||||
onFrame: (engine) => {
|
||||
@@ -179,6 +183,7 @@ export class XZBTApplication {
|
||||
rng: current.performance.rng
|
||||
});
|
||||
canvas.hidden = engine === null;
|
||||
current.performance.actions.visual = engine;
|
||||
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
|
||||
} catch (error) {
|
||||
canvas.hidden = true;
|
||||
@@ -244,11 +249,14 @@ export class XZBTApplication {
|
||||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||||
}
|
||||
await this.audio.unlock();
|
||||
current.performance.setAudio(this.audio);
|
||||
this.renderAudio();
|
||||
}
|
||||
|
||||
async disposeAudio() {
|
||||
if (!this.audio) return;
|
||||
const current = this.activation.current;
|
||||
if (current) current.performance.setAudio(null);
|
||||
await this.audio.dispose();
|
||||
this.audio = null;
|
||||
this.renderAudio();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionExecutor } from './actions.js';
|
||||
import { SeededRNG } from './rng.js';
|
||||
import { ResolutionEngine } from './resolution.js';
|
||||
import { CadenceSubsystem } from './cadence.js';
|
||||
|
||||
export class CommonGrammarPerformance {
|
||||
constructor(record, rootSeed, options = {}) {
|
||||
@@ -12,12 +13,22 @@ export class CommonGrammarPerformance {
|
||||
// Called once per animation frame, after any logical ticks. A frame draws
|
||||
// the most recent tick's state and advances nothing (9.1, 18.2).
|
||||
this.onFrame = options.onFrame ?? (() => {});
|
||||
// Called once per logical tick, with the fixed step of 9.1. Procedural
|
||||
// motion advances here and nowhere else.
|
||||
this.onTick = options.onTick ?? (() => {});
|
||||
this.engine = new ResolutionEngine(record.document, this.rng, {
|
||||
diagnostics: this.diagnostics,
|
||||
initialParameters: options.initialParameters,
|
||||
onParameterChange: options.onParameterChange
|
||||
});
|
||||
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
|
||||
this.cadence = new CadenceSubsystem({
|
||||
document: record.document,
|
||||
rng: this.rng,
|
||||
resolution: this.engine,
|
||||
audio: options.audio ?? null,
|
||||
diagnostics: this.diagnostics
|
||||
});
|
||||
this.state = 'prepared';
|
||||
this.resources = new Set();
|
||||
this.frameRequest = null;
|
||||
@@ -57,7 +68,10 @@ export class CommonGrammarPerformance {
|
||||
const step = 1000 / 60;
|
||||
let ticks = 0;
|
||||
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
||||
this.actions.resetBudget();
|
||||
this.engine.advance(step);
|
||||
this.cadence.advance(step);
|
||||
this.onTick(step, this.engine);
|
||||
this.accumulator -= step;
|
||||
ticks += 1;
|
||||
}
|
||||
@@ -70,6 +84,11 @@ export class CommonGrammarPerformance {
|
||||
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
||||
}
|
||||
|
||||
setAudio(audio) {
|
||||
this.actions.audio = audio;
|
||||
this.cadence.setAudio(audio);
|
||||
}
|
||||
|
||||
setParameter(id, value) {
|
||||
const result = this.engine.setParameter(id, value);
|
||||
this.onUpdate(this.engine);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RuntimeFault } from './types.js';
|
||||
import { sampleAutomationTrack, automationValueAt, applyAutomationMode, resolveNumericStages } from './audio-automation.js';
|
||||
import { visualGetPath, visualSetPath } from './visual-motion.js';
|
||||
|
||||
export function visualLocalTracks(definitions, root, evaluate, warn) {
|
||||
return (definitions ?? []).map(definition => {
|
||||
const track = sampleAutomationTrack(definition, evaluate, warn);
|
||||
let object = root, property = definition.target;
|
||||
if (Array.isArray(root)) {
|
||||
const parts = property.split('.');
|
||||
const first = parts.shift();
|
||||
object = root.find(node => node.key === first);
|
||||
if (!object) {
|
||||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${first}' not found.`);
|
||||
}
|
||||
while (object?.children?.some(node => node.key === parts[0])) {
|
||||
const child = parts.shift();
|
||||
object = object.children.find(node => node.key === child);
|
||||
}
|
||||
if (!object) {
|
||||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${definition.target}' not found.`);
|
||||
}
|
||||
property = parts.join('.');
|
||||
object = object.baseRaw;
|
||||
}
|
||||
const authored = visualGetPath(object, property);
|
||||
const base = authored ?? (/opacity|scale|zoom/.test(property) ? 1 : 0);
|
||||
return { track, object, property, base };
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceVisualTracks(tracks, milliseconds) {
|
||||
for (const { track, object, property, base } of tracks ?? []) {
|
||||
const value = resolveNumericStages(base, { automation: lower => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) });
|
||||
visualSetPath(object, property, property === 'drag' ? Math.max(0, Math.min(1, value)) : property === 'rate' ? Math.max(0, value) : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Frame-only post processing. No scene or procedural random stream is accessible here.
|
||||
import { parseColor } from './visual-math.js';
|
||||
|
||||
function visualBoxBlur(source, width, height, radius) {
|
||||
const maxDeviceRadius = Math.min(Math.max(width, height), 128);
|
||||
const r = Math.min(maxDeviceRadius, Math.max(0, Math.round(radius)));
|
||||
if (!r) return new Uint8ClampedArray(source);
|
||||
const horizontal = new Float32Array(source.length), output = new Uint8ClampedArray(source.length);
|
||||
// Sliding windows keep work linear in pixel count even for large projected radii.
|
||||
for (let axis = 0; axis < 2; axis += 1) {
|
||||
const input = axis ? horizontal : source, target = axis ? output : horizontal;
|
||||
const major = axis ? width : height, minor = axis ? height : width;
|
||||
const offset = (a, b, c) => ((axis ? b * width + a : a * width + b) * 4 + c);
|
||||
for (let a = 0; a < major; a += 1) for (let c = 0; c < 4; c += 1) {
|
||||
let sum = 0;
|
||||
for (let k = -r; k <= r; k += 1) sum += input[offset(a, Math.max(0, Math.min(minor - 1, k)), c)];
|
||||
for (let b = 0; b < minor; b += 1) {
|
||||
target[offset(a, b, c)] = sum / (2 * r + 1);
|
||||
sum += input[offset(a, Math.min(minor - 1, b + r + 1), c)] - input[offset(a, Math.max(0, b - r), c)];
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function applyVisualEffect(pixels, width, height, effect, seconds = 0) {
|
||||
const output = new Uint8ClampedArray(pixels);
|
||||
const color = parseColor(effect.color ?? '#000000');
|
||||
if (effect.type === 'blur') return visualBoxBlur(pixels, width, height, effect.deviceRadius ?? effect.radius ?? 4);
|
||||
if (effect.type === 'bloom') {
|
||||
const bright = new Uint8ClampedArray(pixels.length);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const luminance = (pixels[i] * 0.2126 + pixels[i + 1] * 0.7152 + pixels[i + 2] * 0.0722) / 255;
|
||||
const contribution = luminance > (effect.threshold ?? 0.7) ? 1 : 0;
|
||||
for (let c = 0; c < 3; c += 1) bright[i + c] = pixels[i + c] * contribution;
|
||||
bright[i + 3] = pixels[i + 3];
|
||||
}
|
||||
const glow = visualBoxBlur(bright, width, height, effect.deviceRadius ?? effect.radius ?? 8);
|
||||
for (let i = 0; i < pixels.length; i += 4) for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, pixels[i + c] + glow[i + c] * (effect.intensity ?? 0.6)));
|
||||
return output;
|
||||
}
|
||||
const theta = (effect.hueRotate ?? 0) * Math.PI / 180, cs = Math.cos(theta), sn = Math.sin(theta);
|
||||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||||
const i = (y * width + x) * 4;
|
||||
let amount = effect.amount ?? 0, target = [color.r, color.g, color.b];
|
||||
switch (effect.type) {
|
||||
case 'fade': break;
|
||||
case 'vignette': {
|
||||
const d = Math.hypot((x + 0.5 - width / 2) / (width / 2), (y + 0.5 - height / 2) / (height / 2)) / Math.SQRT2;
|
||||
const edge = Math.max(0.000001, effect.softness ?? 0.5);
|
||||
const u = Math.max(0, Math.min(1, (d - (effect.radius ?? 0.75)) / edge));
|
||||
amount = (effect.amount ?? 0.5) * u * u * (3 - 2 * u); break;
|
||||
}
|
||||
case 'scanlines': {
|
||||
const phase = ((y / (effect.spacing ?? 3) + seconds * (effect.speed ?? 0)) % 1 + 1) % 1;
|
||||
amount = phase < (effect.thickness ?? 0.5) ? (effect.amount ?? 0.3) : 0;
|
||||
target = [0, 0, 0]; break;
|
||||
}
|
||||
case 'grain': {
|
||||
const cell = effect.scale ?? 1;
|
||||
let hash = Math.imul(Math.floor(x / cell) + 1, 374761393) ^ Math.imul(Math.floor(y / cell) + 1, 668265263) ^ Math.imul(Math.floor(seconds * (effect.speed ?? 24)), 1274126177);
|
||||
hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
|
||||
const noise = ((hash ^ (hash >>> 16)) >>> 0) / 4294967296 - 0.5;
|
||||
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] + noise * 255 * (effect.amount ?? 0.15);
|
||||
continue;
|
||||
}
|
||||
case 'color-adjust': {
|
||||
const [r, g, b] = [pixels[i], pixels[i + 1], pixels[i + 2]].map(v => v / 255);
|
||||
const hue = [
|
||||
(0.213 + cs * 0.787 - sn * 0.213) * r + (0.715 - cs * 0.715 - sn * 0.715) * g + (0.072 - cs * 0.072 + sn * 0.928) * b,
|
||||
(0.213 - cs * 0.213 + sn * 0.143) * r + (0.715 + cs * 0.285 + sn * 0.140) * g + (0.072 - cs * 0.072 - sn * 0.283) * b,
|
||||
(0.213 - cs * 0.213 - sn * 0.787) * r + (0.715 - cs * 0.715 + sn * 0.715) * g + (0.072 + cs * 0.928 + sn * 0.072) * b
|
||||
];
|
||||
const l = hue[0] * 0.2126 + hue[1] * 0.7152 + hue[2] * 0.0722;
|
||||
for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, (((l + (hue[c] - l) * (effect.saturation ?? 1)) * (effect.brightness ?? 1) - 0.5) * (effect.contrast ?? 1) + 0.5) * 255));
|
||||
continue;
|
||||
}
|
||||
default: throw new Error(`Unavailable visual effect '${effect.type}'.`);
|
||||
}
|
||||
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] * (1 - amount) + target[c] * amount;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function renderVisualEffects(context, plan, warn = () => {}) {
|
||||
for (const effect of plan.effects ?? []) {
|
||||
try {
|
||||
const frame = context.getImageData(0, 0, plan.backing.width, plan.backing.height);
|
||||
frame.data.set(applyVisualEffect(frame.data, frame.width, frame.height, effect, plan.logicalMilliseconds / 1000));
|
||||
context.putImageData(frame, 0, 0);
|
||||
if (['blur', 'bloom'].includes(effect.type)) warn(effect, 'Separable blur approximation.');
|
||||
} catch { warn(effect, 'Effect unavailable on this rendering surface; skipped.'); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user