trid3d 0.3.1 → 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.
- package/README.md +15 -6
- package/package.json +1 -1
- package/trid/index.d.ts +245 -10
- package/trid/index.js +8 -1
- package/trid/render/canvasRenderer.js +99 -48
- package/trid/render/effects.js +16 -9
- package/trid/render/grid.js +177 -0
- package/trid/render/lights.js +162 -0
- package/trid/render/post.js +325 -0
- package/trid/render/webglRenderer.js +341 -19
- package/trid/scene.js +48 -6
- package/trid/tools/helpers.js +107 -9
- package/trid/tools/index.js +1 -1
- package/trid/tools/transformGizmo.js +40 -6
|
@@ -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
|
+
}
|