@liquid-glass-forge/cpu 0.0.0-stage → 0.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +7 -0
- package/README.md +42 -2
- package/dist/executor.d.ts +63 -0
- package/dist/executor.js +492 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +37 -0
- package/dist/kernel.js +501 -0
- package/dist/protocol.d.ts +45 -0
- package/dist/protocol.js +42 -0
- package/dist/worker-core.js +187 -0
- package/dist/worker-kernel.js +501 -0
- package/dist/worker-protocol.js +42 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +18 -0
- package/package.json +44 -4
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { ContractError, MAX_PIXELS, decodeSrgb, encodeSrgb, validateSource } from './worker-core.js';
|
|
2
|
+
export const CPU_MATERIAL_MODEL = 'rounded-material-v1';
|
|
3
|
+
export const CPU_LENS_MODEL = 'refractive-lens-v2';
|
|
4
|
+
const storage = new WeakMap();
|
|
5
|
+
const ENCODING_STEPS = 4096;
|
|
6
|
+
export const CPU_LOOKUP_BYTES = 12296;
|
|
7
|
+
let colourTables;
|
|
8
|
+
/** Shared fixed storage per realm, separate from prepared-source storage. Lazy at first preparation. */
|
|
9
|
+
export function cpuKernelStorageBytes() { return colourTables === undefined ? 0 : CPU_LOOKUP_BYTES; }
|
|
10
|
+
function getColourTables() {
|
|
11
|
+
if (colourTables === undefined) {
|
|
12
|
+
const decoded = new Float64Array(256), bytes = new Uint16Array(ENCODING_STEPS), thresholds = new Float64Array(257);
|
|
13
|
+
for (let byte = 0; byte < decoded.length; byte++)
|
|
14
|
+
decoded[byte] = decodeSrgb(byte / 255);
|
|
15
|
+
for (let byte = 1; byte <= 255; byte++)
|
|
16
|
+
thresholds[byte] = decodeSrgb((byte - .5) / 255);
|
|
17
|
+
thresholds[256] = Infinity;
|
|
18
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
19
|
+
const guess = Math.round(encodeSrgb((i + .5) / ENCODING_STEPS) * 255);
|
|
20
|
+
const low = i / ENCODING_STEPS, high = (i + 1) / ENCODING_STEPS, guard = 8 * Number.EPSILON;
|
|
21
|
+
const sensitive = (low <= thresholds[guess] + guard && high >= thresholds[guess] - guard)
|
|
22
|
+
|| (low <= thresholds[guess + 1] + guard && high >= thresholds[guess + 1] - guard);
|
|
23
|
+
bytes[i] = guess | (sensitive ? 256 : 0);
|
|
24
|
+
}
|
|
25
|
+
colourTables = { decoded, bytes, thresholds };
|
|
26
|
+
}
|
|
27
|
+
return colourTables;
|
|
28
|
+
}
|
|
29
|
+
// A bucket spans less than one byte at the maximum sRGB slope. Correct the
|
|
30
|
+
// midpoint guess using inverse thresholds, preserving the original floating
|
|
31
|
+
// encode/round decision when an inverse lands extremely close to a half byte.
|
|
32
|
+
function encodeSrgbByte(value, table) {
|
|
33
|
+
if (value >= 1)
|
|
34
|
+
return 255;
|
|
35
|
+
const entry = table.bytes[Math.floor(value * ENCODING_STEPS)];
|
|
36
|
+
if (entry < 256)
|
|
37
|
+
return entry;
|
|
38
|
+
let byte = entry & 255;
|
|
39
|
+
if (value >= table.thresholds[byte + 1])
|
|
40
|
+
byte++;
|
|
41
|
+
else if (value < table.thresholds[byte])
|
|
42
|
+
byte--;
|
|
43
|
+
if (Math.abs(value - table.thresholds[byte]) <= 8 * Number.EPSILON
|
|
44
|
+
|| Math.abs(value - table.thresholds[byte + 1]) <= 8 * Number.EPSILON)
|
|
45
|
+
return Math.round(encodeSrgb(value) * 255);
|
|
46
|
+
return byte;
|
|
47
|
+
}
|
|
48
|
+
const clamp = (v, low = 0, high = 1) => Math.min(high, Math.max(low, v));
|
|
49
|
+
function object(value, allowed, label) {
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
51
|
+
throw new ContractError(`${label} must be an object`);
|
|
52
|
+
if (Object.keys(value).some(key => !allowed.includes(key)))
|
|
53
|
+
throw new ContractError(`Unknown ${label} field`);
|
|
54
|
+
}
|
|
55
|
+
function finite(value, low, high, label) {
|
|
56
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < low || value > high)
|
|
57
|
+
throw new ContractError(`Invalid ${label}`);
|
|
58
|
+
}
|
|
59
|
+
function dimensions(width, height) {
|
|
60
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 || width * height > MAX_PIXELS)
|
|
61
|
+
throw new ContractError('Invalid CPU dimensions');
|
|
62
|
+
}
|
|
63
|
+
function live(prepared) {
|
|
64
|
+
const entry = storage.get(prepared);
|
|
65
|
+
if (!entry)
|
|
66
|
+
throw new ContractError('Unknown or disposed prepared source');
|
|
67
|
+
return entry;
|
|
68
|
+
}
|
|
69
|
+
/** Snapshots all caller bytes. Only opaque metadata escapes; no caller resource is transferred. */
|
|
70
|
+
export function prepareCpuSource(source) {
|
|
71
|
+
validateSource(source);
|
|
72
|
+
const data = new Float32Array(source.data.length);
|
|
73
|
+
const decoded = getColourTables().decoded;
|
|
74
|
+
let opaque = true;
|
|
75
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
76
|
+
const a = source.data[i + 3] / 255;
|
|
77
|
+
data[i] = decoded[source.data[i]] * a;
|
|
78
|
+
data[i + 1] = decoded[source.data[i + 1]] * a;
|
|
79
|
+
data[i + 2] = decoded[source.data[i + 2]] * a;
|
|
80
|
+
data[i + 3] = a;
|
|
81
|
+
if (a !== 1)
|
|
82
|
+
opaque = false;
|
|
83
|
+
}
|
|
84
|
+
const prepared = Object.freeze({ width: source.width, height: source.height, revision: source.revision });
|
|
85
|
+
storage.set(prepared, { data, opaque });
|
|
86
|
+
return prepared;
|
|
87
|
+
}
|
|
88
|
+
export function disposeCpuSource(prepared) { storage.delete(prepared); }
|
|
89
|
+
export function preparedStorageBytes(prepared) {
|
|
90
|
+
const s = storage.get(prepared);
|
|
91
|
+
return s ? s.data.byteLength + (s.blur?.byteLength ?? 0) + (s.scratch?.byteLength ?? 0) + (s.geometry?.bytes ?? 0) : 0;
|
|
92
|
+
}
|
|
93
|
+
export const CPU_GEOMETRY_MAX_BYTES = 270000;
|
|
94
|
+
function geometryCache(s, w, h, r, b, preferResampling = false) {
|
|
95
|
+
const n = Math.ceil(Math.max(0, r - .5)), bytes = (w + h + n * n * 4) * 8;
|
|
96
|
+
if (w * h < 4096 || bytes > CPU_GEOMETRY_MAX_BYTES) {
|
|
97
|
+
delete s.geometry;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const identityBytes = Math.ceil(w * h / 8);
|
|
101
|
+
const sourceResampling = preferResampling && bytes + w * h * 3 + identityBytes <= CPU_GEOMETRY_MAX_BYTES;
|
|
102
|
+
const key = `${w}/${h}/${r}/${b}/${sourceResampling}`;
|
|
103
|
+
if (s.geometry?.key === key)
|
|
104
|
+
return s.geometry;
|
|
105
|
+
const xs = new Float64Array(w), ys = new Float64Array(h), corners = new Float64Array(n * n * 4);
|
|
106
|
+
for (let x = 0; x < w; x++)
|
|
107
|
+
xs[x] = geometry(w, h, r, b, x, Math.floor(h / 2)).weight;
|
|
108
|
+
for (let y = 0; y < h; y++)
|
|
109
|
+
ys[y] = geometry(w, h, r, b, Math.floor(w / 2), y).weight;
|
|
110
|
+
for (let y = 0; y < n; y++)
|
|
111
|
+
for (let x = 0; x < n; x++) {
|
|
112
|
+
const g = geometry(w, h, r, b, w - 1 - x, h - 1 - y), i = (y * n + x) * 4;
|
|
113
|
+
corners[i] = g.coverage;
|
|
114
|
+
corners[i + 1] = g.normalX;
|
|
115
|
+
corners[i + 2] = g.normalY;
|
|
116
|
+
corners[i + 3] = g.weight;
|
|
117
|
+
}
|
|
118
|
+
const identity = sourceResampling ? new Uint8Array(identityBytes) : undefined;
|
|
119
|
+
if (identity)
|
|
120
|
+
for (let y = 0; y < h; y++)
|
|
121
|
+
for (let x = 0; x < w; x++) {
|
|
122
|
+
const g = geometry(w, h, r, b, x, y), pixel = y * w + x;
|
|
123
|
+
if (g.coverage === 1 && g.weight === 0)
|
|
124
|
+
identity[pixel >> 3] |= 1 << (pixel & 7);
|
|
125
|
+
}
|
|
126
|
+
const scratchBytes = (w * 2 + h) * Float64Array.BYTES_PER_ELEMENT;
|
|
127
|
+
const classes = !sourceResampling && s.opaque && bytes + w * h + scratchBytes <= CPU_GEOMETRY_MAX_BYTES ? new Uint8Array(w * h) : undefined;
|
|
128
|
+
if (classes)
|
|
129
|
+
for (let y = 0; y < h; y++)
|
|
130
|
+
for (let x = 0; x < w; x++) {
|
|
131
|
+
const px = x + .5 - w / 2, py = y + .5 - h / 2, ex = Math.min(x, w - 1 - x), ey = Math.min(y, h - 1 - y);
|
|
132
|
+
let kind = 1; // Exact identity region, including a zero-normal center.
|
|
133
|
+
if (ex < n && ey < n)
|
|
134
|
+
kind = Math.round(corners[(ey * n + ex) * 4] * 255) === 0 ? 0 : 6;
|
|
135
|
+
else if (Math.abs(px) - w / 2 > Math.abs(py) - h / 2) {
|
|
136
|
+
if (xs[x] !== 0 && px !== 0)
|
|
137
|
+
kind = px < 0 ? 2 : 3;
|
|
138
|
+
}
|
|
139
|
+
else if (ys[y] !== 0 && py !== 0)
|
|
140
|
+
kind = py < 0 ? 4 : 5;
|
|
141
|
+
classes[y * w + x] = kind;
|
|
142
|
+
}
|
|
143
|
+
return s.geometry = { key, bytes: bytes + (classes?.byteLength ?? 0) + (classes ? scratchBytes : 0) + (identity?.byteLength ?? 0), n, corners, xs, ys, sourceResampling,
|
|
144
|
+
...(identity ? { identity } : {}),
|
|
145
|
+
...(classes ? { classes, axes: { baseX: new Float64Array(w), dx: new Float64Array(w), dy: new Float64Array(h) } } : {}) };
|
|
146
|
+
}
|
|
147
|
+
/** Exact source resampling only: independent of mask, coverage, strength and material. */
|
|
148
|
+
function resampledSourceRgb(prepared, data, surface, geo, encoding) {
|
|
149
|
+
const { width, height, sourceRect: rect } = surface;
|
|
150
|
+
const key = `${rect.x}/${rect.y}/${rect.width}/${rect.height}`;
|
|
151
|
+
if (geo.resampled?.key === key)
|
|
152
|
+
return geo.resampled.rgb;
|
|
153
|
+
delete geo.resampled;
|
|
154
|
+
geo.bytes = geo.corners.byteLength + geo.xs.byteLength + geo.ys.byteLength + (geo.identity?.byteLength ?? 0);
|
|
155
|
+
const rgb = new Uint8Array(width * height * 3), stride = prepared.width * 4;
|
|
156
|
+
for (let y = 0; y < height; y++)
|
|
157
|
+
for (let x = 0; x < width; x++) {
|
|
158
|
+
const a = ((rect.y + y * 2) * prepared.width + rect.x + x * 2) * 4, b = a + 4, c = a + stride, d = c + 4;
|
|
159
|
+
const index = (y * width + x) * 3;
|
|
160
|
+
// Same exact Float32-domain half/half mean proved in the classified path.
|
|
161
|
+
for (let k = 0; k < 3; k++)
|
|
162
|
+
rgb[index + k] = encodeSrgbByte((data[a + k] + data[b + k] + data[c + k] + data[d + k]) * .25, encoding);
|
|
163
|
+
}
|
|
164
|
+
geo.bytes += rgb.byteLength;
|
|
165
|
+
geo.resampled = { key, rgb };
|
|
166
|
+
return rgb;
|
|
167
|
+
}
|
|
168
|
+
function cachedGeometry(c, w, h, r, b, x, y) {
|
|
169
|
+
if (!c)
|
|
170
|
+
return geometry(w, h, r, b, x, y);
|
|
171
|
+
const px = x + .5 - w / 2, py = y + .5 - h / 2, ex = Math.min(x, w - 1 - x), ey = Math.min(y, h - 1 - y);
|
|
172
|
+
if (ex < c.n && ey < c.n) {
|
|
173
|
+
const i = (ey * c.n + ex) * 4;
|
|
174
|
+
return { distance: 0, coverage: c.corners[i], normalX: c.corners[i + 1] * Math.sign(px), normalY: c.corners[i + 2] * Math.sign(py), weight: c.corners[i + 3] };
|
|
175
|
+
}
|
|
176
|
+
const dominantX = Math.abs(px) - w / 2 > Math.abs(py) - h / 2;
|
|
177
|
+
return { distance: 0, coverage: 1, normalX: dominantX ? Math.sign(px) : 0, normalY: dominantX ? 0 : Math.sign(py), weight: dominantX ? c.xs[x] : c.ys[y] };
|
|
178
|
+
}
|
|
179
|
+
function geometry(width, height, radius, bevel, x, y) {
|
|
180
|
+
const px = x + .5 - width / 2, py = y + .5 - height / 2;
|
|
181
|
+
const qx = Math.abs(px) - (width / 2 - radius), qy = Math.abs(py) - (height / 2 - radius);
|
|
182
|
+
const ax = Math.max(qx, 0), ay = Math.max(qy, 0), length = ax === 0 ? ay : ay === 0 ? ax : Math.hypot(ax, ay);
|
|
183
|
+
const distance = length + Math.min(Math.max(qx, qy), 0) - radius;
|
|
184
|
+
let normalX = 0, normalY = 0;
|
|
185
|
+
if (length > 0) {
|
|
186
|
+
normalX = ax / length * Math.sign(px);
|
|
187
|
+
normalY = ay / length * Math.sign(py);
|
|
188
|
+
}
|
|
189
|
+
else if (Math.abs(px) - width / 2 > Math.abs(py) - height / 2)
|
|
190
|
+
normalX = Math.sign(px);
|
|
191
|
+
else
|
|
192
|
+
normalY = Math.sign(py);
|
|
193
|
+
return { distance, normalX, normalY, coverage: clamp(.5 - distance), weight: clamp(1 + distance / bevel) ** 2 };
|
|
194
|
+
}
|
|
195
|
+
export function sampleGeometry(width, height, radius, bevel, x, y) {
|
|
196
|
+
dimensions(width, height);
|
|
197
|
+
finite(radius, 0, Math.min(width, height) / 2, 'radius');
|
|
198
|
+
finite(bevel, Number.MIN_VALUE, 1_000_000, 'bevel');
|
|
199
|
+
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= height)
|
|
200
|
+
throw new ContractError('Invalid geometry pixel');
|
|
201
|
+
return Object.freeze(geometry(width, height, radius, bevel, x, y));
|
|
202
|
+
}
|
|
203
|
+
function surfaceOptions(input, source) {
|
|
204
|
+
object(input, ['id', 'width', 'height', 'radius', 'bevel', 'strength', 'sourceRect'], 'surface');
|
|
205
|
+
const { width, height } = input;
|
|
206
|
+
dimensions(width, height);
|
|
207
|
+
if (typeof input.id !== 'string' || input.id.length < 1 || input.id.length > 128)
|
|
208
|
+
throw new ContractError('Invalid surface id');
|
|
209
|
+
const radius = input.radius === undefined ? Math.min(width, height) / 4 : input.radius;
|
|
210
|
+
const bevel = input.bevel === undefined ? Math.min(width, height) / 5 : input.bevel, strength = input.strength === undefined ? 12 : input.strength;
|
|
211
|
+
finite(radius, 0, Math.min(width, height) / 2, 'radius');
|
|
212
|
+
finite(bevel, Number.MIN_VALUE, 1_000_000, 'bevel');
|
|
213
|
+
finite(strength, 0, 1_000_000, 'strength');
|
|
214
|
+
const rect = input.sourceRect === undefined ? { x: 0, y: 0, width: source.width, height: source.height } : input.sourceRect;
|
|
215
|
+
object(rect, ['x', 'y', 'width', 'height'], 'sourceRect');
|
|
216
|
+
finite(rect.x, 0, source.width, 'sourceRect.x');
|
|
217
|
+
finite(rect.y, 0, source.height, 'sourceRect.y');
|
|
218
|
+
finite(rect.width, Number.MIN_VALUE, source.width, 'sourceRect.width');
|
|
219
|
+
finite(rect.height, Number.MIN_VALUE, source.height, 'sourceRect.height');
|
|
220
|
+
if (rect.x + rect.width > source.width || rect.y + rect.height > source.height)
|
|
221
|
+
throw new ContractError('Source rectangle exceeds source');
|
|
222
|
+
return { id: input.id, width, height, radius, bevel, strength, sourceRect: rect };
|
|
223
|
+
}
|
|
224
|
+
function effectOptions(input) {
|
|
225
|
+
if (input !== undefined) {
|
|
226
|
+
object(input, ['model', 'blurRadius', 'dispersion', 'tint', 'rim', 'specular', 'light'], 'effects');
|
|
227
|
+
if (input.model !== CPU_MATERIAL_MODEL && input.model !== CPU_LENS_MODEL)
|
|
228
|
+
throw new ContractError('Unsupported CPU material model');
|
|
229
|
+
}
|
|
230
|
+
const blurRadius = input?.blurRadius === undefined ? 0 : input.blurRadius, dispersion = input?.dispersion === undefined ? 0 : input.dispersion;
|
|
231
|
+
const rim = input?.rim === undefined ? 0 : input.rim, specular = input?.specular === undefined ? 0 : input.specular;
|
|
232
|
+
finite(blurRadius, 0, 16, 'blurRadius');
|
|
233
|
+
if (!Number.isInteger(blurRadius))
|
|
234
|
+
throw new ContractError('blurRadius must be an integer');
|
|
235
|
+
finite(dispersion, 0, 1, 'dispersion');
|
|
236
|
+
finite(rim, 0, 1, 'rim');
|
|
237
|
+
finite(specular, 0, 1, 'specular');
|
|
238
|
+
const tint = input?.tint === undefined ? [0, 0, 0, 0] : input.tint;
|
|
239
|
+
const light = input?.light === undefined ? [-Math.SQRT1_2, -Math.SQRT1_2] : input.light;
|
|
240
|
+
if (!Array.isArray(tint) || tint.length !== 4)
|
|
241
|
+
throw new ContractError('Tint requires four normalized channels');
|
|
242
|
+
for (let i = 0; i < 4; i++)
|
|
243
|
+
finite(tint[i], 0, 1, 'tint');
|
|
244
|
+
if (!Array.isArray(light) || light.length !== 2)
|
|
245
|
+
throw new ContractError('Light requires a nonzero finite direction');
|
|
246
|
+
for (let i = 0; i < 2; i++)
|
|
247
|
+
finite(light[i], -Number.MAX_VALUE, Number.MAX_VALUE, 'light');
|
|
248
|
+
const scale = Math.max(Math.abs(light[0]), Math.abs(light[1]));
|
|
249
|
+
if (scale === 0)
|
|
250
|
+
throw new ContractError('Light direction must be nonzero');
|
|
251
|
+
const lx = light[0] / scale, ly = light[1] / scale, length = Math.hypot(lx, ly);
|
|
252
|
+
return { lens: input?.model === CPU_LENS_MODEL, blurRadius, dispersion, tint: [decodeSrgb(tint[0]), decodeSrgb(tint[1]), decodeSrgb(tint[2]), tint[3]], rim, specular, light: [lx / length, ly / length] };
|
|
253
|
+
}
|
|
254
|
+
/** Validate and freeze a data-only snapshot before asynchronous execution. */
|
|
255
|
+
export function snapshotCpuEffects(input) {
|
|
256
|
+
effectOptions(input);
|
|
257
|
+
if (input === undefined)
|
|
258
|
+
return undefined;
|
|
259
|
+
return Object.freeze({ ...input,
|
|
260
|
+
...(input.tint === undefined ? {} : { tint: Object.freeze([...input.tint]) }),
|
|
261
|
+
...(input.light === undefined ? {} : { light: Object.freeze([...input.light]) }) });
|
|
262
|
+
}
|
|
263
|
+
/** Two sliding box passes; storage is reused for the next radius, never accumulated. */
|
|
264
|
+
function blurred(source, s, radius) {
|
|
265
|
+
if (radius === 0)
|
|
266
|
+
return s.data;
|
|
267
|
+
if (s.radius === radius)
|
|
268
|
+
return s.blur;
|
|
269
|
+
s.scratch ??= new Float32Array(s.data.length);
|
|
270
|
+
s.blur ??= new Float32Array(s.data.length);
|
|
271
|
+
const width = source.width, height = source.height, divisor = 2 * radius + 1;
|
|
272
|
+
for (let y = 0; y < height; y++)
|
|
273
|
+
for (let c = 0; c < 4; c++) {
|
|
274
|
+
let sum = 0;
|
|
275
|
+
for (let k = -radius; k <= radius; k++)
|
|
276
|
+
sum += s.data[(y * width + clamp(k, 0, width - 1)) * 4 + c];
|
|
277
|
+
for (let x = 0; x < width; x++) {
|
|
278
|
+
s.scratch[(y * width + x) * 4 + c] = sum / divisor;
|
|
279
|
+
sum += s.data[(y * width + clamp(x + radius + 1, 0, width - 1)) * 4 + c] - s.data[(y * width + clamp(x - radius, 0, width - 1)) * 4 + c];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (let x = 0; x < width; x++)
|
|
283
|
+
for (let c = 0; c < 4; c++) {
|
|
284
|
+
let sum = 0;
|
|
285
|
+
for (let k = -radius; k <= radius; k++)
|
|
286
|
+
sum += s.scratch[(clamp(k, 0, height - 1) * width + x) * 4 + c];
|
|
287
|
+
for (let y = 0; y < height; y++) {
|
|
288
|
+
s.blur[(y * width + x) * 4 + c] = sum / divisor;
|
|
289
|
+
sum += s.scratch[(clamp(y + radius + 1, 0, height - 1) * width + x) * 4 + c] - s.scratch[(clamp(y - radius, 0, height - 1) * width + x) * 4 + c];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
s.radius = radius;
|
|
293
|
+
return s.blur;
|
|
294
|
+
}
|
|
295
|
+
function sample(data, width, height, x, y, out) {
|
|
296
|
+
const sx = clamp(x, 0, width - 1), sy = clamp(y, 0, height - 1), x0 = Math.floor(sx), y0 = Math.floor(sy);
|
|
297
|
+
const tx = sx - x0, ty = sy - y0;
|
|
298
|
+
const a = (y0 * width + x0) * 4, b = (y0 * width + Math.min(x0 + 1, width - 1)) * 4;
|
|
299
|
+
const c = (Math.min(y0 + 1, height - 1) * width + x0) * 4, d = (Math.min(y0 + 1, height - 1) * width + Math.min(x0 + 1, width - 1)) * 4;
|
|
300
|
+
for (let k = 0; k < 4; k++)
|
|
301
|
+
out[k] = (data[a + k] * (1 - tx) + data[b + k] * tx) * (1 - ty) + (data[c + k] * (1 - tx) + data[d + k] * tx) * ty;
|
|
302
|
+
}
|
|
303
|
+
const byteBrand = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag).get;
|
|
304
|
+
const byteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength').get;
|
|
305
|
+
/** Classification caches geometry decisions only; optical strength is evaluated on every call. */
|
|
306
|
+
function renderClassifiedOpaque(prepared, surface, data, geo, encoding, output) {
|
|
307
|
+
const { width, height, strength, sourceRect: rect } = surface;
|
|
308
|
+
const sourceWidth = prepared.width, maxX = sourceWidth - 1, maxY = prepared.height - 1;
|
|
309
|
+
const scaleX = rect.width / width, scaleY = rect.height / height, classes = geo.classes, axes = geo.axes;
|
|
310
|
+
const halfTexels = scaleX === 2 && scaleY === 2 && Number.isInteger(rect.x) && Number.isInteger(rect.y);
|
|
311
|
+
const sourceStride = sourceWidth * 4;
|
|
312
|
+
// Scratch is recomputed for current optical state, rather than cached across
|
|
313
|
+
// strength/crop changes. It contains coordinates, never sampled source pixels.
|
|
314
|
+
for (let x = 0; x < width; x++) {
|
|
315
|
+
axes.baseX[x] = rect.x + (x + .5) * scaleX - .5;
|
|
316
|
+
axes.dx[x] = Math.sign(x + .5 - width / 2) * strength * geo.xs[x] * scaleX;
|
|
317
|
+
}
|
|
318
|
+
for (let y = 0; y < height; y++)
|
|
319
|
+
axes.dy[y] = Math.sign(y + .5 - height / 2) * strength * geo.ys[y] * scaleY;
|
|
320
|
+
let index = 0;
|
|
321
|
+
for (let y = 0; y < height; y++) {
|
|
322
|
+
const baseY = rect.y + (y + .5) * scaleY - .5;
|
|
323
|
+
const halfRow = ((rect.y + y * 2) * sourceWidth + rect.x) * 4;
|
|
324
|
+
for (let x = 0; x < width; x++, index += 4) {
|
|
325
|
+
const kind = classes[index / 4];
|
|
326
|
+
if (kind === 0) {
|
|
327
|
+
output[index] = 0;
|
|
328
|
+
output[index + 1] = 0;
|
|
329
|
+
output[index + 2] = 0;
|
|
330
|
+
output[index + 3] = 0;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (halfTexels && kind === 1) {
|
|
334
|
+
// Opaque sRGB8-decoded Float32 values are multiples of 2^-35.
|
|
335
|
+
// Four sum to <=4, so every addition fits binary64 exactly (<38 bits).
|
|
336
|
+
// This mean equals half/half bilinear interpolation, including byte ties.
|
|
337
|
+
const a = halfRow + x * 8, b = a + 4, c = a + sourceStride, d = c + 4;
|
|
338
|
+
output[index] = encodeSrgbByte((data[a] + data[b] + data[c] + data[d]) * .25, encoding);
|
|
339
|
+
output[index + 1] = encodeSrgbByte((data[a + 1] + data[b + 1] + data[c + 1] + data[d + 1]) * .25, encoding);
|
|
340
|
+
output[index + 2] = encodeSrgbByte((data[a + 2] + data[b + 2] + data[c + 2] + data[d + 2]) * .25, encoding);
|
|
341
|
+
output[index + 3] = 255;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
let dx = 0, dy = 0, alphaByte = 255;
|
|
345
|
+
if (kind === 2 || kind === 3)
|
|
346
|
+
dx = axes.dx[x];
|
|
347
|
+
else if (kind === 4 || kind === 5)
|
|
348
|
+
dy = axes.dy[y];
|
|
349
|
+
else if (kind === 6) {
|
|
350
|
+
const ex = Math.min(x, width - 1 - x), ey = Math.min(y, height - 1 - y), at = (ey * geo.n + ex) * 4;
|
|
351
|
+
const nx = geo.corners[at + 1] * Math.sign(x + .5 - width / 2), ny = geo.corners[at + 2] * Math.sign(y + .5 - height / 2);
|
|
352
|
+
const weight = geo.corners[at + 3];
|
|
353
|
+
dx = nx * strength * weight * scaleX;
|
|
354
|
+
dy = ny * strength * weight * scaleY;
|
|
355
|
+
alphaByte = Math.round(geo.corners[at] * 255);
|
|
356
|
+
}
|
|
357
|
+
const baseX = axes.baseX[x];
|
|
358
|
+
const rawX = baseX + dx, rawY = baseY + dy;
|
|
359
|
+
const sx = rawX < 0 ? 0 : rawX > maxX ? maxX : rawX, sy = rawY < 0 ? 0 : rawY > maxY ? maxY : rawY;
|
|
360
|
+
// Validated source dimensions total at most MAX_PIXELS (four million).
|
|
361
|
+
// Clamped nonnegative coordinates therefore fit exact signed32 truncation.
|
|
362
|
+
const x0 = sx | 0, y0 = sy | 0, x1 = x0 < maxX ? x0 + 1 : x0, y1 = y0 < maxY ? y0 + 1 : y0;
|
|
363
|
+
const tx = sx - x0, ty = sy - y0, oneX = 1 - tx, oneY = 1 - ty;
|
|
364
|
+
const a = (y0 * sourceWidth + x0) * 4, b = (y0 * sourceWidth + x1) * 4;
|
|
365
|
+
const c = (y1 * sourceWidth + x0) * 4, d = (y1 * sourceWidth + x1) * 4;
|
|
366
|
+
const red = (data[a] * oneX + data[b] * tx) * oneY + (data[c] * oneX + data[d] * tx) * ty;
|
|
367
|
+
const green = (data[a + 1] * oneX + data[b + 1] * tx) * oneY + (data[c + 1] * oneX + data[d + 1] * tx) * ty;
|
|
368
|
+
const blue = (data[a + 2] * oneX + data[b + 2] * tx) * oneY + (data[c + 2] * oneX + data[d + 2] * tx) * ty;
|
|
369
|
+
output[index + 3] = alphaByte;
|
|
370
|
+
// Convex interpolation is nonnegative; encodeSrgbByte already saturates >=1.
|
|
371
|
+
output[index] = encodeSrgbByte(red, encoding);
|
|
372
|
+
output[index + 1] = encodeSrgbByte(green, encoding);
|
|
373
|
+
output[index + 2] = encodeSrgbByte(blue, encoding);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return output;
|
|
377
|
+
}
|
|
378
|
+
/** Pure JavaScript optics, straight-alpha sRGB RGBA8 output. No Canvas or WebGL access. */
|
|
379
|
+
export function renderCpu(prepared, input, effects, output) {
|
|
380
|
+
const s = live(prepared), surface = surfaceOptions(input, prepared), e = effectOptions(effects);
|
|
381
|
+
const { width, height, radius, bevel, strength, sourceRect: rect } = surface;
|
|
382
|
+
// A bevel wider than the corner's curvature reaches the SDF medial axis,
|
|
383
|
+
// where the nearest edge (and its normal) changes discontinuously. Keep the
|
|
384
|
+
// v2 ray profile outside that axis; a sharp corner has only subpixel bevel.
|
|
385
|
+
// The versioned v1 material retains its original geometry.
|
|
386
|
+
const opticalBevel = e.lens ? Math.min(bevel, Math.max(radius, .5)) : bevel;
|
|
387
|
+
if (output === undefined)
|
|
388
|
+
output = new Uint8ClampedArray(width * height * 4);
|
|
389
|
+
try {
|
|
390
|
+
byteLength.call(output.buffer);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
throw new ContractError('CPU output requires an attached unshared buffer');
|
|
394
|
+
}
|
|
395
|
+
if (byteBrand.call(output) !== 'Uint8ClampedArray' || output.byteLength !== width * height * 4)
|
|
396
|
+
throw new ContractError('Invalid CPU output storage');
|
|
397
|
+
const data = blurred(prepared, s, e.blurRadius), scaleX = rect.width / width, scaleY = rect.height / height;
|
|
398
|
+
const encoding = getColourTables();
|
|
399
|
+
const baseline = !e.lens && e.blurRadius === 0 && e.dispersion === 0 && e.tint[3] === 0 && e.rim === 0 && e.specular === 0;
|
|
400
|
+
const preferResampling = baseline && s.opaque && scaleX === 2 && scaleY === 2 && Number.isInteger(rect.x) && Number.isInteger(rect.y);
|
|
401
|
+
const geo = baseline ? geometryCache(s, width, height, radius, bevel, preferResampling) : undefined;
|
|
402
|
+
const sourceRgb = geo?.sourceResampling ? resampledSourceRgb(prepared, data, surface, geo, encoding) : undefined;
|
|
403
|
+
if (baseline && s.opaque && geo?.classes)
|
|
404
|
+
return renderClassifiedOpaque(prepared, surface, data, geo, encoding, output);
|
|
405
|
+
const center = [0, 0, 0, 0], shifted = [0, 0, 0, 0], rgb = [0, 0, 0];
|
|
406
|
+
for (let y = 0; y < height; y++)
|
|
407
|
+
for (let x = 0; x < width; x++) {
|
|
408
|
+
const index = (y * width + x) * 4;
|
|
409
|
+
const pixel = y * width + x;
|
|
410
|
+
if (sourceRgb && (geo.identity[pixel >> 3] & (1 << (pixel & 7)))) {
|
|
411
|
+
const at = pixel * 3;
|
|
412
|
+
output[index] = sourceRgb[at];
|
|
413
|
+
output[index + 1] = sourceRgb[at + 1];
|
|
414
|
+
output[index + 2] = sourceRgb[at + 2];
|
|
415
|
+
output[index + 3] = 255;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
let coverage = 1, normalX = 0, normalY = 0, weight = 0;
|
|
419
|
+
if (baseline && geo) {
|
|
420
|
+
const px = x + .5 - width / 2, py = y + .5 - height / 2, ex = Math.min(x, width - 1 - x), ey = Math.min(y, height - 1 - y);
|
|
421
|
+
if (ex < geo.n && ey < geo.n) {
|
|
422
|
+
const i = (ey * geo.n + ex) * 4;
|
|
423
|
+
coverage = geo.corners[i];
|
|
424
|
+
normalX = geo.corners[i + 1] * Math.sign(px);
|
|
425
|
+
normalY = geo.corners[i + 2] * Math.sign(py);
|
|
426
|
+
weight = geo.corners[i + 3];
|
|
427
|
+
}
|
|
428
|
+
else if (Math.abs(px) - width / 2 > Math.abs(py) - height / 2) {
|
|
429
|
+
normalX = Math.sign(px);
|
|
430
|
+
weight = geo.xs[x];
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
normalY = Math.sign(py);
|
|
434
|
+
weight = geo.ys[y];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
const g = geometry(width, height, radius, opticalBevel, x, y);
|
|
439
|
+
coverage = g.coverage;
|
|
440
|
+
normalX = g.normalX;
|
|
441
|
+
normalY = g.normalY;
|
|
442
|
+
weight = g.weight;
|
|
443
|
+
}
|
|
444
|
+
if (coverage === 0) {
|
|
445
|
+
output.fill(0, index, index + 4);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const baseX = rect.x + (x + .5) * scaleX - .5, baseY = rect.y + (y + .5) * scaleY - .5;
|
|
449
|
+
// Opt-in v2: trace an air-to-glass ray through an elliptical bevel
|
|
450
|
+
// (eta = 1 / 1.5). Backdrop sampling bends inward; v1 remains unchanged.
|
|
451
|
+
let displacement = weight;
|
|
452
|
+
if (e.lens && weight > 0) {
|
|
453
|
+
const u = Math.sqrt(weight), nz = Math.sqrt(Math.max(0, 1 - u * u)), eta = 2 / 3;
|
|
454
|
+
const bend = Math.sqrt(1 - eta * eta * u * u) - eta * nz;
|
|
455
|
+
displacement = -u * bend / (eta + bend * nz);
|
|
456
|
+
}
|
|
457
|
+
const dx = normalX * strength * displacement * scaleX, dy = normalY * strength * displacement * scaleY;
|
|
458
|
+
if (sourceRgb && dx === 0 && dy === 0) {
|
|
459
|
+
output[index + 3] = Math.round(coverage * 255);
|
|
460
|
+
const at = (y * width + x) * 3;
|
|
461
|
+
output[index] = output[index + 3] === 0 ? 0 : sourceRgb[at];
|
|
462
|
+
output[index + 1] = output[index + 3] === 0 ? 0 : sourceRgb[at + 1];
|
|
463
|
+
output[index + 2] = output[index + 3] === 0 ? 0 : sourceRgb[at + 2];
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (baseline && s.opaque) {
|
|
467
|
+
const sx = clamp(baseX + dx, 0, prepared.width - 1), sy = clamp(baseY + dy, 0, prepared.height - 1), x0 = Math.floor(sx), y0 = Math.floor(sy), tx = sx - x0, ty = sy - y0;
|
|
468
|
+
const a = (y0 * prepared.width + x0) * 4, b = (y0 * prepared.width + Math.min(x0 + 1, prepared.width - 1)) * 4, c = (Math.min(y0 + 1, prepared.height - 1) * prepared.width + x0) * 4, d = (Math.min(y0 + 1, prepared.height - 1) * prepared.width + Math.min(x0 + 1, prepared.width - 1)) * 4;
|
|
469
|
+
output[index + 3] = Math.round(coverage * 255);
|
|
470
|
+
for (let k = 0; k < 3; k++)
|
|
471
|
+
output[index + k] = output[index + 3] === 0 ? 0 : encodeSrgbByte(clamp((data[a + k] * (1 - tx) + data[b + k] * tx) * (1 - ty) + (data[c + k] * (1 - tx) + data[d + k] * tx) * ty), encoding);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
sample(data, prepared.width, prepared.height, baseX + dx, baseY + dy, center);
|
|
475
|
+
const alpha = center[3];
|
|
476
|
+
output[index + 3] = Math.round(clamp(alpha) * coverage * 255);
|
|
477
|
+
if (output[index + 3] === 0) {
|
|
478
|
+
output.fill(0, index, index + 3);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
for (let c = 0; c < 3; c++)
|
|
482
|
+
rgb[c] = alpha > 0 ? clamp(center[c] / alpha) : 0;
|
|
483
|
+
if (e.dispersion > 0)
|
|
484
|
+
for (const c of [0, 2]) {
|
|
485
|
+
const multiplier = c === 0 ? 1 + e.dispersion : 1 - e.dispersion;
|
|
486
|
+
sample(data, prepared.width, prepared.height, baseX + dx * multiplier, baseY + dy * multiplier, shifted);
|
|
487
|
+
rgb[c] = shifted[3] > 0 ? clamp(shifted[c] / shifted[3]) : 0;
|
|
488
|
+
}
|
|
489
|
+
if (baseline) {
|
|
490
|
+
for (let c = 0; c < 3; c++)
|
|
491
|
+
output[index + c] = encodeSrgbByte(rgb[c], encoding);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const highlight = clamp(weight * (e.rim + e.specular * Math.max(normalX * e.light[0] + normalY * e.light[1], 0) ** 8));
|
|
495
|
+
for (let c = 0; c < 3; c++) {
|
|
496
|
+
const tinted = rgb[c] * (1 - e.tint[3]) + e.tint[c] * e.tint[3];
|
|
497
|
+
output[index + c] = encodeSrgbByte(clamp(tinted + (1 - tinted) * highlight), encoding);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return output;
|
|
501
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createFrame, OPTICAL_MODEL, validateSource } from './worker-core.js';
|
|
2
|
+
import { prepareCpuSource, renderCpu, disposeCpuSource, preparedStorageBytes } from './worker-kernel.js';
|
|
3
|
+
export function checkedFrame(source, frame) {
|
|
4
|
+
validateSource(source);
|
|
5
|
+
if (!frame || frame.opticalModel !== OPTICAL_MODEL || Object.keys(frame).some(k => !['contractVersion', 'opticalModel', 'generation', 'revision', 'sourceRevision', 'surfaces'].includes(k)))
|
|
6
|
+
throw new TypeError('Invalid CPU frame model');
|
|
7
|
+
return createFrame({ contractVersion: frame.contractVersion, generation: frame.generation, revision: frame.revision, sourceRevision: frame.sourceRevision, surfaces: frame.surfaces }, source);
|
|
8
|
+
}
|
|
9
|
+
/** Same batch implementation is used in the main thread and the standalone worker graph. */
|
|
10
|
+
export class CpuBatchSession {
|
|
11
|
+
#key;
|
|
12
|
+
#source;
|
|
13
|
+
get storageBytes() { return this.#source ? preparedStorageBytes(this.#source) : 0; }
|
|
14
|
+
prepare(source, frame) {
|
|
15
|
+
const key = `${frame.generation}/${frame.sourceRevision}/${source.width}/${source.height}`;
|
|
16
|
+
if (this.#key !== key) {
|
|
17
|
+
this.dispose();
|
|
18
|
+
this.#source = prepareCpuSource(source);
|
|
19
|
+
this.#key = key;
|
|
20
|
+
}
|
|
21
|
+
return this.#source;
|
|
22
|
+
}
|
|
23
|
+
run(work) { return runCpuBatch(work, this); }
|
|
24
|
+
dispose() { if (this.#source)
|
|
25
|
+
disposeCpuSource(this.#source); this.#source = undefined; this.#key = undefined; }
|
|
26
|
+
}
|
|
27
|
+
export function runCpuBatch(work, session) {
|
|
28
|
+
const frame = checkedFrame(work.source, work.frame), start = performance.now(), prepared = session ? session.prepare(work.source, frame) : prepareCpuSource(work.source);
|
|
29
|
+
const prepareMs = performance.now() - start, kernelStart = performance.now();
|
|
30
|
+
try {
|
|
31
|
+
if (work.outputs.length !== frame.surfaces.length)
|
|
32
|
+
throw new TypeError('Invalid CPU output count');
|
|
33
|
+
const surfaces = frame.surfaces.map((surface, i) => ({ id: surface.id, width: surface.width, height: surface.height,
|
|
34
|
+
data: renderCpu(prepared, surface, work.effects, work.outputs[i]) }));
|
|
35
|
+
return { generation: frame.generation, revision: frame.revision, sourceRevision: frame.sourceRevision, surfaces, retainedBytes: session ? preparedStorageBytes(prepared) : 0,
|
|
36
|
+
timings: { snapshotMs: 0, transferCopyMs: 0, prepareMs, kernelMs: performance.now() - kernelStart, transferSubmitMs: 0, roundTripMs: 0 } };
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
if (!session)
|
|
40
|
+
disposeCpuSource(prepared);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCpuBatch, CpuBatchSession } from './worker-protocol.js';
|
|
2
|
+
// This entry is an explicit worker asset, never imported by the SSR-safe root.
|
|
3
|
+
const scope = globalThis;
|
|
4
|
+
const session = new CpuBatchSession();
|
|
5
|
+
scope.onmessage = event => {
|
|
6
|
+
const work = event.data;
|
|
7
|
+
try {
|
|
8
|
+
if (work?.type !== 'render' || !Number.isSafeInteger(work.id))
|
|
9
|
+
throw new TypeError('Invalid CPU worker request');
|
|
10
|
+
if (!work.cacheSources)
|
|
11
|
+
session.dispose();
|
|
12
|
+
const result = work.cacheSources ? session.run(work) : runCpuBatch(work);
|
|
13
|
+
scope.postMessage({ type: 'result', id: work.id, result, returnedInput: work.source.data }, [work.source.data.buffer, ...result.surfaces.map(s => s.data.buffer)]);
|
|
14
|
+
}
|
|
15
|
+
catch (cause) {
|
|
16
|
+
scope.postMessage({ type: 'error', id: work?.id, message: cause instanceof Error ? cause.message : 'CPU worker failed' }, []);
|
|
17
|
+
}
|
|
18
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liquid-glass-forge/cpu",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.2",
|
|
4
|
+
"description": "Experimental JavaScript CPU optics for explicit Liquid Glass Forge pixel sources",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./dist/worker.js"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20.19.0"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./worker.js": "./dist/worker.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"NOTICE",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@liquid-glass-forge/core": "0.1.0-rc.2"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://namipsg.github.io/liquid-glass-forge/#playground",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/namipsg/liquid-glass-forge.git",
|
|
35
|
+
"directory": "packages/cpu"
|
|
36
|
+
},
|
|
37
|
+
"workers": [
|
|
38
|
+
"dist/worker.js",
|
|
39
|
+
"dist/worker-protocol.js",
|
|
40
|
+
"dist/worker-kernel.js",
|
|
41
|
+
"dist/worker-core.js"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|