trid3d 0.3.0 → 0.4.0

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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Post effects: shaders run on the whole picture after the scene is drawn
3
+ * (bloom, vignette, color grading, film grain...). Tools draw on top
4
+ * afterwards, so handles and overlays aren't affected.
5
+ *
6
+ * scene.post = [bloom(), grade({ saturation: 1.2 }), vignette()];
7
+ *
8
+ * They run in the list's order, each on the result of the one before.
9
+ * A post effect is a plain object, like a material effect:
10
+ * - `glsl`: GLSL code defining `vec4 post(vec4 color, Frame f)`, run once
11
+ * per pixel (WebGL renderer). `color` is the picture at `f.uv`
12
+ * - `passes`: instead of `glsl`, a list of passes run one after the other,
13
+ * each `{ glsl, scale }` (`scale`: size of that pass's picture, 0.5 = half,
14
+ * cheaper for blurs). Bloom is made this way
15
+ * - `canvas`: optional `(ctx, frame, uniforms) => void` that changes the 2D
16
+ * canvas directly. The canvas renderer uses it (WebGL too, for effects
17
+ * without `glsl` or `passes`). Without it, the canvas renderer skips the effect
18
+ * - `uniforms`: settings used by the code, like `{ uStrength: 0.5 }`. Same
19
+ * types as material effects; read every frame
20
+ * - `enabled: false` turns the effect off without removing it
21
+ *
22
+ * In GLSL, every pass can call:
23
+ * - `frame(uv)`: the previous pass's picture (the first pass: the picture
24
+ * this effect starts from)
25
+ * - `source(uv)`: the picture this effect starts from
26
+ * Colors are premultiplied by alpha (the same as plain colors where the
27
+ * picture is opaque, which it is when the scene has an opaque background).
28
+ *
29
+ * With post effects on, the WebGL renderer draws the scene into a texture
30
+ * first. On WebGL 2 it keeps antialiasing (multisampling); on WebGL 1 edges
31
+ * get a little rougher.
32
+ */
33
+
34
+ import { parseColor } from "./color.js";
35
+
36
+ /**
37
+ * What a post effect knows about the picture. GLSL fields and the canvas
38
+ * function's `frame` argument have the same names.
39
+ *
40
+ * @typedef {Object} Frame
41
+ * @property {{x:number,y:number}} uv - GLSL only: position in the picture, 0..1 (0, 0 = bottom left)
42
+ * @property {{x:number,y:number}} size - Size of the picture being made, in pixels (GLSL: `vec2`;
43
+ * canvas: `width` / `height` instead)
44
+ * @property {{x:number,y:number}} texel - GLSL only: one pixel of the input picture, in uv units
45
+ * @property {number} time - Seconds since the renderer started
46
+ * @property {number[]} background - The scene's background color [r, g, b, a]
47
+ */
48
+
49
+ /** The GLSL declaration of `Frame`, and the `frame` / `source` helpers. */
50
+ export const FRAME_GLSL = `
51
+ struct Frame {
52
+ vec2 uv;
53
+ vec2 size;
54
+ vec2 texel;
55
+ float time;
56
+ vec4 background;
57
+ };
58
+ uniform sampler2D uFrame;
59
+ uniform sampler2D uSource;
60
+ vec4 frame(vec2 uv) { return texture2D(uFrame, uv); }
61
+ vec4 source(vec2 uv) { return texture2D(uSource, uv); }
62
+ `;
63
+
64
+ /** A post effect's passes, as `{ glsl, scale }`. Empty when it has no GLSL (canvas only). */
65
+ export function postPasses(effect) {
66
+ if (Array.isArray(effect.passes)) return effect.passes.filter((p) => p?.glsl).map((p) => ({ glsl: p.glsl, scale: p.scale > 0 ? p.scale : 1 }));
67
+ return effect.glsl ? [{ glsl: effect.glsl, scale: 1 }] : [];
68
+ }
69
+
70
+ /** The effects of a list that are on. */
71
+ export function activePost(list) {
72
+ return Array.isArray(list) ? list.filter((e) => e && e.enabled !== false) : [];
73
+ }
74
+
75
+ /**
76
+ * Runs post effects' `canvas` functions on a scene's 2D canvas.
77
+ * Used by the renderers after drawing.
78
+ *
79
+ * @param {Object} scene
80
+ * @param {Object[]} list - Post effects (the ones off, or without `canvas`, are skipped)
81
+ * @param {number} start - When the renderer started (`performance.now()`), for `time`
82
+ */
83
+ export function runCanvasPost(scene, list, start) {
84
+ const effects = activePost(list).filter((e) => typeof e.canvas === "function");
85
+ if (!effects.length) return;
86
+ const { ctx, canvas } = scene;
87
+ const frame = {
88
+ width: canvas.width,
89
+ height: canvas.height,
90
+ time: (performance.now() - start) / 1000,
91
+ canvas,
92
+ rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
93
+ };
94
+ frame.background = frame.rgba(scene.background ?? "#000000");
95
+ for (const e of effects) {
96
+ ctx.save();
97
+ try {
98
+ e.canvas(ctx, frame, e.uniforms ?? {});
99
+ } finally {
100
+ ctx.restore();
101
+ }
102
+ }
103
+ }
104
+
105
+ // --- Built-in post effects ---
106
+
107
+ /**
108
+ * Darkens the corners of the picture.
109
+ *
110
+ * @param {Object} [opts]
111
+ * @param {number} [opts.strength=0.5] - How dark the corners get (0..1)
112
+ * @param {number} [opts.radius=0.5] - Where the darkening starts: 0 = center, 1 = corners
113
+ * @param {number} [opts.softness=0.5] - How far it takes to reach `strength`
114
+ * @param {string} [opts.color="#000000"] - Color the corners turn to
115
+ */
116
+ export function vignette({ strength = 0.5, radius = 0.5, softness = 0.5, color = "#000000" } = {}) {
117
+ return {
118
+ name: "vignette",
119
+ uniforms: { uStrength: strength, uRadius: radius, uSoftness: softness, uColor: color },
120
+ glsl: `
121
+ vec4 post(vec4 color, Frame f) {
122
+ float r = length((f.uv - 0.5) * 2.0) / 1.41421356; // 0 at the center, 1 in the corners
123
+ float v = smoothstep(uRadius, uRadius + max(uSoftness, 1e-3), r) * uStrength;
124
+ return vec4(mix(color.rgb, uColor.rgb * color.a, v), color.a);
125
+ }`,
126
+ canvas(ctx, f, u) {
127
+ const [r, g, b] = f.rgba(u.uColor);
128
+ const rgba = (a) => `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
129
+ ctx.translate(f.width / 2, f.height / 2);
130
+ ctx.scale(f.width / 2, f.height / 2); // the picture is -1..1 both ways: the circle becomes an ellipse
131
+ const r0 = u.uRadius * Math.SQRT2, r1 = (u.uRadius + Math.max(u.uSoftness, 1e-3)) * Math.SQRT2;
132
+ const grad = ctx.createRadialGradient(0, 0, r0, 0, 0, r1);
133
+ for (const t of [0, 0.25, 0.5, 0.75, 1]) grad.addColorStop(t, rgba(t * t * (3 - 2 * t) * u.uStrength));
134
+ ctx.fillStyle = grad;
135
+ ctx.fillRect(-1, -1, 2, 2);
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Color grading: brightness, contrast, saturation and a tint, in that order
142
+ * (like the CSS filters of the same names).
143
+ *
144
+ * @param {Object} [opts]
145
+ * @param {number} [opts.brightness=1] - Multiplies the colors (1 = unchanged)
146
+ * @param {number} [opts.contrast=1] - Pushes colors away from (> 1) or toward (< 1) middle gray
147
+ * @param {number} [opts.saturation=1] - 0 = gray, 1 = unchanged, > 1 = more vivid
148
+ * @param {string} [opts.tint="#ffffff"] - Multiplies the colors by this one
149
+ */
150
+ export function grade({ brightness = 1, contrast = 1, saturation = 1, tint = "#ffffff" } = {}) {
151
+ return {
152
+ name: "grade",
153
+ uniforms: { uBrightness: brightness, uContrast: contrast, uSaturation: saturation, uTint: tint },
154
+ glsl: `
155
+ vec4 post(vec4 color, Frame f) {
156
+ if (color.a <= 0.0) return color;
157
+ vec3 c = color.rgb / color.a;
158
+ c = clamp(c * uBrightness, 0.0, 1.0);
159
+ c = clamp((c - 0.5) * uContrast + 0.5, 0.0, 1.0);
160
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
161
+ c = clamp(mix(vec3(l), c, uSaturation), 0.0, 1.0) * uTint.rgb;
162
+ return vec4(c * color.a, color.a);
163
+ }`,
164
+ canvas(ctx, f, u) {
165
+ if (supportsFilter(ctx) && (u.uBrightness !== 1 || u.uContrast !== 1 || u.uSaturation !== 1)) {
166
+ const copy = scratch(f.width, f.height);
167
+ copy.getContext("2d").drawImage(f.canvas, 0, 0);
168
+ ctx.filter = `brightness(${u.uBrightness}) contrast(${u.uContrast}) saturate(${u.uSaturation})`;
169
+ ctx.globalCompositeOperation = "copy";
170
+ ctx.drawImage(copy, 0, 0);
171
+ ctx.filter = "none";
172
+ }
173
+ const t = f.rgba(u.uTint);
174
+ if (t[0] < 1 || t[1] < 1 || t[2] < 1) {
175
+ ctx.globalCompositeOperation = "multiply";
176
+ ctx.fillStyle = u.uTint;
177
+ ctx.fillRect(0, 0, f.width, f.height);
178
+ }
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Film grain: a little random noise over the picture.
185
+ *
186
+ * @param {Object} [opts]
187
+ * @param {number} [opts.amount=0.08] - How strong the noise is (0..1)
188
+ * @param {number} [opts.size=1] - Size of a grain, in pixels
189
+ * @param {boolean} [opts.animated=true] - New noise every frame (false: the same noise stays)
190
+ */
191
+ export function grain({ amount = 0.08, size = 1, animated = true } = {}) {
192
+ return {
193
+ name: "grain",
194
+ uniforms: { uAmount: amount, uGrain: size, uAnimated: animated },
195
+ glsl: `
196
+ float grainHash(vec2 p) {
197
+ vec3 q = fract(vec3(p.xyx) * 0.1031);
198
+ q += dot(q, q.yzx + 33.33);
199
+ return fract((q.x + q.y) * q.z);
200
+ }
201
+ vec4 post(vec4 color, Frame f) {
202
+ vec2 cell = floor(f.uv * f.size / max(uGrain, 1.0));
203
+ float t = uAnimated > 0.5 ? floor(f.time * 24.0) : 0.0;
204
+ float n = grainHash(cell + vec2(t * 17.0, t * 31.0)) - 0.5;
205
+ return vec4(clamp(color.rgb + n * uAmount * color.a, 0.0, 1.0), color.a);
206
+ }`,
207
+ canvas(ctx, f, u) {
208
+ const tile = noiseTile();
209
+ const pattern = ctx.createPattern(tile, "repeat");
210
+ const s = Math.max(1, u.uGrain);
211
+ const frameNo = u.uAnimated ? Math.floor(f.time * 24) : 0;
212
+ // A different spot of the tile each frame
213
+ const ox = (frameNo * 37) % tile.width, oy = (frameNo * 61) % tile.height;
214
+ pattern.setTransform?.(new DOMMatrix([s, 0, 0, s, -ox * s, -oy * s]));
215
+ ctx.globalCompositeOperation = "overlay";
216
+ ctx.globalAlpha = Math.min(1, u.uAmount * 4);
217
+ ctx.fillStyle = pattern;
218
+ ctx.fillRect(0, 0, f.width, f.height);
219
+ },
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Glow around bright parts: what's brighter than `threshold` is blurred
225
+ * and added back on top.
226
+ *
227
+ * On the canvas renderer it's an approximation (CSS filters can't cut at a
228
+ * brightness exactly), and it needs a browser with canvas `filter` support.
229
+ *
230
+ * @param {Object} [opts]
231
+ * @param {number} [opts.threshold=0.7] - Brightness (0..1) where the glow starts
232
+ * @param {number} [opts.strength=0.8] - How bright the glow is
233
+ * @param {number} [opts.radius=1] - How far it spreads (1 = about 25 pixels)
234
+ */
235
+ export function bloom({ threshold = 0.7, strength = 0.8, radius = 1 } = {}) {
236
+ // 9-tap Gaussian blur, along one direction
237
+ const blur = (dir) => `
238
+ vec4 post(vec4 color, Frame f) {
239
+ vec2 step = ${dir} * f.texel * uRadius * 1.5;
240
+ vec3 c = frame(f.uv).rgb * 0.227027;
241
+ c += (frame(f.uv + step).rgb + frame(f.uv - step).rgb) * 0.1945946;
242
+ c += (frame(f.uv + step * 2.0).rgb + frame(f.uv - step * 2.0).rgb) * 0.1216216;
243
+ c += (frame(f.uv + step * 3.0).rgb + frame(f.uv - step * 3.0).rgb) * 0.054054;
244
+ c += (frame(f.uv + step * 4.0).rgb + frame(f.uv - step * 4.0).rgb) * 0.016216;
245
+ return vec4(c, 1.0);
246
+ }`;
247
+ return {
248
+ name: "bloom",
249
+ uniforms: { uThreshold: threshold, uStrength: strength, uRadius: radius },
250
+ passes: [
251
+ {
252
+ // The bright parts only, at half size (four pixels averaged into one)
253
+ scale: 0.5,
254
+ glsl: `
255
+ vec4 post(vec4 color, Frame f) {
256
+ vec2 o = f.texel * 0.5;
257
+ vec3 c = (frame(f.uv - o).rgb + frame(f.uv + o).rgb
258
+ + frame(f.uv + vec2(o.x, -o.y)).rgb + frame(f.uv + vec2(-o.x, o.y)).rgb) * 0.25;
259
+ float b = max(c.r, max(c.g, c.b));
260
+ return vec4(c * smoothstep(uThreshold - 0.1, uThreshold + 0.1, b), 1.0);
261
+ }`,
262
+ },
263
+ { scale: 0.25, glsl: blur("vec2(1.0, 0.0)") },
264
+ { scale: 0.25, glsl: blur("vec2(0.0, 1.0)") },
265
+ {
266
+ // Added on top of the picture
267
+ scale: 1,
268
+ glsl: `
269
+ vec4 post(vec4 color, Frame f) {
270
+ vec4 s = source(f.uv);
271
+ vec3 g = frame(f.uv).rgb * uStrength;
272
+ return vec4(min(s.rgb + g, 1.0), min(1.0, max(s.a, max(g.r, max(g.g, g.b)))));
273
+ }`,
274
+ },
275
+ ],
276
+ canvas(ctx, f, u) {
277
+ if (!supportsFilter(ctx)) return;
278
+ const copy = scratch(f.width, f.height);
279
+ const c = copy.getContext("2d");
280
+ c.clearRect(0, 0, f.width, f.height);
281
+ // brightness, then strong contrast: what's darker than about `threshold` goes black
282
+ const t = Math.min(0.99, Math.max(0.05, u.uThreshold));
283
+ c.filter = `brightness(${0.375 / t}) contrast(4) blur(${Math.max(1, 10 * u.uRadius)}px)`;
284
+ c.drawImage(f.canvas, 0, 0);
285
+ c.filter = "none";
286
+ ctx.globalCompositeOperation = "lighter";
287
+ ctx.globalAlpha = Math.min(1, u.uStrength);
288
+ ctx.drawImage(copy, 0, 0);
289
+ },
290
+ };
291
+ }
292
+
293
+ // --- Helpers for the canvas versions ---
294
+
295
+ function supportsFilter(ctx) {
296
+ return typeof ctx.filter === "string";
297
+ }
298
+
299
+ let scratchCanvas = null;
300
+ /** A spare canvas of a size (one, reused). */
301
+ function scratch(w, h) {
302
+ scratchCanvas ??= document.createElement("canvas");
303
+ if (scratchCanvas.width !== w || scratchCanvas.height !== h) {
304
+ scratchCanvas.width = w;
305
+ scratchCanvas.height = h;
306
+ }
307
+ return scratchCanvas;
308
+ }
309
+
310
+ let tile = null;
311
+ /** 128 × 128 gray noise, made once. */
312
+ function noiseTile() {
313
+ if (tile) return tile;
314
+ tile = document.createElement("canvas");
315
+ tile.width = tile.height = 128;
316
+ const c = tile.getContext("2d");
317
+ const img = c.createImageData(128, 128);
318
+ for (let i = 0; i < img.data.length; i += 4) {
319
+ const v = Math.floor(Math.random() * 256);
320
+ img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
321
+ img.data[i + 3] = 255;
322
+ }
323
+ c.putImageData(img, 0, 0);
324
+ return tile;
325
+ }