@opsydyn/effect-three 0.4.0 → 0.6.4
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/CHANGELOG.md +41 -0
- package/README.md +4 -2
- package/dist/{index-h3vf50fn.js → index-re3rrfwd.js} +880 -79
- package/dist/index-re3rrfwd.js.map +56 -0
- package/dist/index.js +71 -2
- package/dist/index.js.map +3 -3
- package/dist/kernel/bodies.d.ts +20 -1
- package/dist/kernel/bodies.d.ts.map +1 -1
- package/dist/kernel/build.d.ts +24 -2
- package/dist/kernel/build.d.ts.map +1 -1
- package/dist/kernel/description.d.ts +68 -0
- package/dist/kernel/description.d.ts.map +1 -1
- package/dist/kernel/equality.d.ts +3 -1
- package/dist/kernel/equality.d.ts.map +1 -1
- package/dist/kernel/index.d.ts +7 -5
- package/dist/kernel/index.d.ts.map +1 -1
- package/dist/kernel/index.js +20 -2
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/loop.d.ts.map +1 -1
- package/dist/kernel/paintOrder.d.ts +53 -0
- package/dist/kernel/paintOrder.d.ts.map +1 -0
- package/dist/kernel/physics.d.ts +39 -0
- package/dist/kernel/physics.d.ts.map +1 -1
- package/dist/kernel/pointer.d.ts +19 -1
- package/dist/kernel/pointer.d.ts.map +1 -1
- package/dist/kernel/preview.d.ts +32 -0
- package/dist/kernel/preview.d.ts.map +1 -0
- package/dist/kernel/sceneGraph.d.ts +3 -1
- package/dist/kernel/sceneGraph.d.ts.map +1 -1
- package/dist/kernel/sceneIndex.d.ts +5 -0
- package/dist/kernel/sceneIndex.d.ts.map +1 -1
- package/dist/kernel/timeline.d.ts +60 -0
- package/dist/kernel/timeline.d.ts.map +1 -0
- package/dist/runtime/SceneFile.d.ts +14 -0
- package/dist/runtime/SceneFile.d.ts.map +1 -1
- package/dist/runtime/SceneSchema.d.ts +86 -0
- package/dist/runtime/SceneSchema.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/kernel/bodies.ts +299 -17
- package/src/kernel/build.ts +42 -1
- package/src/kernel/description.ts +68 -0
- package/src/kernel/equality.ts +22 -0
- package/src/kernel/index.ts +13 -1
- package/src/kernel/loop.ts +2 -0
- package/src/kernel/paintOrder.ts +274 -0
- package/src/kernel/physics.ts +86 -1
- package/src/kernel/pointer.ts +43 -5
- package/src/kernel/preview.ts +467 -0
- package/src/kernel/sceneGraph.ts +35 -3
- package/src/kernel/sceneIndex.ts +8 -0
- package/src/kernel/timeline.ts +93 -0
- package/src/runtime/SceneSchema.ts +72 -0
- package/dist/index-h3vf50fn.js.map +0 -53
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A still of a scene as SVG text, drawn without a browser or a GPU: for previews made on a
|
|
3
|
+
* server, in a build or in a test. The description is applied to a scene graph as it is
|
|
4
|
+
* for the renderer, posed at a moment of scene time, projected with three's `Projector`
|
|
5
|
+
* and painted as flat-shaded paths, as three's `SVGRenderer` does
|
|
6
|
+
* (`examples/jsm/renderers/SVGRenderer.js` at 0.186.1), but in the order a binary space
|
|
7
|
+
* partition gives (`paintOrder.ts`) rather than by depth, so that faces meeting (a floor
|
|
8
|
+
* and what stands on it) are painted in the right order. Each face is lit by the diffuse
|
|
9
|
+
* terms of three's shaders (`lights_pars_begin` and `lights_physical_pars_fragment` at
|
|
10
|
+
* 0.186.1): ambient and hemisphere light, then directional, point and spot light by the
|
|
11
|
+
* face's normal with their distance and cone falloff, over π (`BRDF_Lambert`).
|
|
12
|
+
*
|
|
13
|
+
* It is a preview, not the picture: no textures, shadows, specular highlights, fog,
|
|
14
|
+
* environment lighting, tone mapping, post-processing, views, HUD or render-kit parts
|
|
15
|
+
* (sky, shader grids, wide lines), and no labels. Borrowed models and text are drawn when
|
|
16
|
+
* `store` lends their objects and fonts. Skinned models stand in their rest pose.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
Box2,
|
|
22
|
+
Color,
|
|
23
|
+
type ColorSpace,
|
|
24
|
+
DirectionalLight,
|
|
25
|
+
HemisphereLight,
|
|
26
|
+
InstancedMesh,
|
|
27
|
+
LinearSRGBColorSpace,
|
|
28
|
+
LineBasicMaterial,
|
|
29
|
+
LineDashedMaterial,
|
|
30
|
+
type Material,
|
|
31
|
+
Matrix3,
|
|
32
|
+
Matrix4,
|
|
33
|
+
Mesh,
|
|
34
|
+
MeshBasicMaterial,
|
|
35
|
+
MeshLambertMaterial,
|
|
36
|
+
MeshNormalMaterial,
|
|
37
|
+
MeshPhongMaterial,
|
|
38
|
+
MeshStandardMaterial,
|
|
39
|
+
MeshToonMaterial,
|
|
40
|
+
Light,
|
|
41
|
+
Line,
|
|
42
|
+
type Object3D,
|
|
43
|
+
PointLight,
|
|
44
|
+
Points,
|
|
45
|
+
Scene,
|
|
46
|
+
Sprite,
|
|
47
|
+
PointsMaterial,
|
|
48
|
+
SpotLight,
|
|
49
|
+
SpriteMaterial,
|
|
50
|
+
SRGBColorSpace,
|
|
51
|
+
Vector2,
|
|
52
|
+
Vector3,
|
|
53
|
+
AmbientLight,
|
|
54
|
+
} from 'three';
|
|
55
|
+
import {
|
|
56
|
+
Projector,
|
|
57
|
+
RenderableFace,
|
|
58
|
+
RenderableLine,
|
|
59
|
+
RenderableSprite,
|
|
60
|
+
} from 'three/addons/renderers/Projector.js';
|
|
61
|
+
|
|
62
|
+
import type { Rgb, SceneDescription } from './description.ts';
|
|
63
|
+
import {
|
|
64
|
+
type Facet,
|
|
65
|
+
type Mark,
|
|
66
|
+
paintOrder,
|
|
67
|
+
type Plane,
|
|
68
|
+
split,
|
|
69
|
+
type Unit,
|
|
70
|
+
unitOf,
|
|
71
|
+
} from './paintOrder.ts';
|
|
72
|
+
import type { ResourceStore } from './resources.ts';
|
|
73
|
+
import { createSceneGraph, type SceneGraphOptions } from './sceneGraph.ts';
|
|
74
|
+
import type { DescriptionError } from './sceneIndex.ts';
|
|
75
|
+
|
|
76
|
+
export type PreviewOptions = {
|
|
77
|
+
/** The picture's size in pixels; the camera takes its aspect ratio. */
|
|
78
|
+
readonly width: number;
|
|
79
|
+
readonly height: number;
|
|
80
|
+
/** The moment of scene time shown, in seconds: behaviours and clips as they are then. */
|
|
81
|
+
readonly at?: number;
|
|
82
|
+
/** A colour behind the scene; transparent without one. */
|
|
83
|
+
readonly background?: Rgb | null;
|
|
84
|
+
/** Lends what borrowed nodes, maps by id and text name; nothing is lent without one. */
|
|
85
|
+
readonly store?: ResourceStore;
|
|
86
|
+
/** Behaviours nodes may name, beyond the built-in ones, as for the scene graph. */
|
|
87
|
+
readonly behaviours?: SceneGraphOptions['behaviours'];
|
|
88
|
+
/** Digits after the decimal point in coordinates. */
|
|
89
|
+
readonly precision?: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type PreviewResult =
|
|
93
|
+
| { readonly _tag: 'Preview'; readonly svg: string }
|
|
94
|
+
| { readonly _tag: 'Invalid'; readonly error: DescriptionError };
|
|
95
|
+
|
|
96
|
+
/** Pixels each edge of a face is widened by, as `SVGRenderer`'s `overdraw`. */
|
|
97
|
+
const overdraw = 0.5;
|
|
98
|
+
|
|
99
|
+
const black = new Color(0, 0, 0);
|
|
100
|
+
|
|
101
|
+
/** Diffuse irradiance on a face from every light, before `BRDF_Lambert`'s 1 / π. */
|
|
102
|
+
const irradianceAt = (
|
|
103
|
+
lights: ReadonlyArray<Light>,
|
|
104
|
+
position: Vector3,
|
|
105
|
+
normal: Vector3,
|
|
106
|
+
into: Color,
|
|
107
|
+
): Color => {
|
|
108
|
+
into.copy(black);
|
|
109
|
+
const lightPosition = new Vector3();
|
|
110
|
+
const toLight = new Vector3();
|
|
111
|
+
const direction = new Vector3();
|
|
112
|
+
for (const light of lights) {
|
|
113
|
+
const color = light.color.clone().multiplyScalar(light.intensity);
|
|
114
|
+
if (light instanceof AmbientLight) {
|
|
115
|
+
into.add(color);
|
|
116
|
+
} else if (light instanceof HemisphereLight) {
|
|
117
|
+
lightPosition.setFromMatrixPosition(light.matrixWorld).normalize();
|
|
118
|
+
const weight = 0.5 * normal.dot(lightPosition) + 0.5;
|
|
119
|
+
into.add(light.groundColor.clone().lerp(light.color, weight).multiplyScalar(light.intensity));
|
|
120
|
+
} else if (light instanceof DirectionalLight) {
|
|
121
|
+
lightPosition.setFromMatrixPosition(light.matrixWorld);
|
|
122
|
+
direction.setFromMatrixPosition(light.target.matrixWorld);
|
|
123
|
+
toLight.subVectors(lightPosition, direction).normalize();
|
|
124
|
+
into.add(color.multiplyScalar(Math.max(normal.dot(toLight), 0)));
|
|
125
|
+
} else if (light instanceof PointLight || light instanceof SpotLight) {
|
|
126
|
+
lightPosition.setFromMatrixPosition(light.matrixWorld);
|
|
127
|
+
toLight.subVectors(lightPosition, position);
|
|
128
|
+
const distance = toLight.length();
|
|
129
|
+
toLight.normalize();
|
|
130
|
+
const cone = light instanceof SpotLight ? spotAttenuation(light, toLight) : 1;
|
|
131
|
+
const falloff = distanceAttenuation(distance, light.distance, light.decay);
|
|
132
|
+
into.add(color.multiplyScalar(Math.max(normal.dot(toLight), 0) * cone * falloff));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return into;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** three's `getDistanceAttenuation`. */
|
|
139
|
+
const distanceAttenuation = (distance: number, cutoff: number, decay: number): number => {
|
|
140
|
+
const falloff = 1 / Math.max(distance ** decay, 0.01);
|
|
141
|
+
return cutoff > 0
|
|
142
|
+
? falloff * Math.min(Math.max(1 - (distance / cutoff) ** 4, 0), 1) ** 2
|
|
143
|
+
: falloff;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const smoothstep = (low: number, high: number, x: number): number => {
|
|
147
|
+
const t = Math.min(Math.max((x - low) / (high - low), 0), 1);
|
|
148
|
+
return t * t * (3 - 2 * t);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** three's `getSpotAttenuation`, with the cone and penumbra cosines `WebGLLights` gives it. */
|
|
152
|
+
const spotAttenuation = (light: SpotLight, toLight: Vector3): number => {
|
|
153
|
+
const direction = new Vector3()
|
|
154
|
+
.setFromMatrixPosition(light.matrixWorld)
|
|
155
|
+
.sub(new Vector3().setFromMatrixPosition(light.target.matrixWorld))
|
|
156
|
+
.normalize();
|
|
157
|
+
return smoothstep(
|
|
158
|
+
Math.cos(light.angle),
|
|
159
|
+
Math.cos(light.angle * (1 - light.penumbra)),
|
|
160
|
+
direction.dot(toLight),
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
type Painter = {
|
|
165
|
+
readonly lights: ReadonlyArray<Light>;
|
|
166
|
+
readonly normalView: Matrix3;
|
|
167
|
+
readonly colorSpace: ColorSpace;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A face's colour, as `SVGRenderer.renderFace3` with three's physical diffuse term, lit
|
|
172
|
+
* from its object's `centre` so that the faces of one flat side share a colour; `undefined`
|
|
173
|
+
* for a material it does not draw. A metal is lit as if matte: without the environment it
|
|
174
|
+
* reflects, three would draw it nearly black.
|
|
175
|
+
*/
|
|
176
|
+
const faceColor = (
|
|
177
|
+
painter: Painter,
|
|
178
|
+
face: RenderableFace,
|
|
179
|
+
material: Material,
|
|
180
|
+
centre: Vector3,
|
|
181
|
+
): Color | undefined => {
|
|
182
|
+
if (material instanceof MeshBasicMaterial) {
|
|
183
|
+
const color = material.color.clone();
|
|
184
|
+
return material.vertexColors ? color.multiply(face.color) : color;
|
|
185
|
+
}
|
|
186
|
+
if (material instanceof MeshNormalMaterial) {
|
|
187
|
+
const normal = face.normalModel.clone().applyMatrix3(painter.normalView).normalize();
|
|
188
|
+
return new Color(normal.x, normal.y, normal.z).multiplyScalar(0.5).addScalar(0.5);
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
material instanceof MeshLambertMaterial ||
|
|
192
|
+
material instanceof MeshPhongMaterial ||
|
|
193
|
+
material instanceof MeshStandardMaterial ||
|
|
194
|
+
material instanceof MeshToonMaterial
|
|
195
|
+
) {
|
|
196
|
+
const diffuse = material.color.clone();
|
|
197
|
+
if (material.vertexColors) diffuse.multiply(face.color);
|
|
198
|
+
return irradianceAt(painter.lights, centre, face.normalModel, new Color())
|
|
199
|
+
.multiply(diffuse)
|
|
200
|
+
.multiplyScalar(1 / Math.PI)
|
|
201
|
+
.add(material.emissive.clone().multiplyScalar(material.emissiveIntensity));
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** A colour as SVG writes it, clamped to what a display shows. */
|
|
207
|
+
const styleOf = (color: Color, colorSpace: ColorSpace): string => {
|
|
208
|
+
const shown = new Color(
|
|
209
|
+
Math.min(Math.max(color.r, 0), 1),
|
|
210
|
+
Math.min(Math.max(color.g, 0), 1),
|
|
211
|
+
Math.min(Math.max(color.b, 0), 1),
|
|
212
|
+
);
|
|
213
|
+
return `#${shown.getHexString(colorSpace)}`;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** What a material's opacity is worth: three blends only a transparent material. */
|
|
217
|
+
const opacityOf = (material: Material): number => (material.transparent ? material.opacity : 1);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Stands in for what an object draws, posed where it is in the world, for `Projector` to
|
|
221
|
+
* project alone: each instance of an instanced mesh as a mesh of its own (`Projector` draws
|
|
222
|
+
* an instanced mesh's geometry once), its colour copied into its material.
|
|
223
|
+
*/
|
|
224
|
+
const standInsFor = (
|
|
225
|
+
object: Object3D,
|
|
226
|
+
): ReadonlyArray<{ readonly standIn: Object3D; readonly tinted?: Material }> => {
|
|
227
|
+
if (!(object instanceof InstancedMesh)) {
|
|
228
|
+
const standIn = object.clone(false);
|
|
229
|
+
standIn.matrix.copy(object.matrixWorld);
|
|
230
|
+
return [{ standIn }];
|
|
231
|
+
}
|
|
232
|
+
const material = Array.isArray(object.material) ? object.material[0] : object.material;
|
|
233
|
+
if (material === undefined) return [];
|
|
234
|
+
const matrix = new Matrix4();
|
|
235
|
+
const standIns: Array<{ readonly standIn: Object3D; readonly tinted?: Material }> = [];
|
|
236
|
+
for (let i = 0; i < object.count; i++) {
|
|
237
|
+
object.getMatrixAt(i, matrix);
|
|
238
|
+
const tinted = object.instanceColor === null ? undefined : material.clone();
|
|
239
|
+
if (tinted !== undefined && 'color' in tinted && tinted.color instanceof Color) {
|
|
240
|
+
tinted.color.multiply(object.getColorAt(i, new Color()));
|
|
241
|
+
}
|
|
242
|
+
const standIn = new Mesh(object.geometry, tinted ?? material);
|
|
243
|
+
standIn.matrix.multiplyMatrices(object.matrixWorld, matrix);
|
|
244
|
+
standIn.renderOrder = object.renderOrder;
|
|
245
|
+
standIns.push(tinted === undefined ? { standIn } : { standIn, tinted });
|
|
246
|
+
}
|
|
247
|
+
return standIns;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const number =
|
|
251
|
+
(precision: number) =>
|
|
252
|
+
(value: number): string =>
|
|
253
|
+
String(Number(value.toFixed(precision)));
|
|
254
|
+
|
|
255
|
+
/** A shape's paint, and for a mark its outline on the picture. */
|
|
256
|
+
type Item = { readonly paint: string; readonly d: string };
|
|
257
|
+
|
|
258
|
+
/** Widens each edge of a polygon on the picture by `overdraw` pixels, as `SVGRenderer.expand`. */
|
|
259
|
+
const widened = (points: ReadonlyArray<Vector2>): ReadonlyArray<Vector2> => {
|
|
260
|
+
const out = points.map((point) => point.clone());
|
|
261
|
+
for (const [i, from] of out.entries()) {
|
|
262
|
+
const to = out[(i + 1) % out.length];
|
|
263
|
+
const length = to === undefined ? 0 : from.distanceTo(to);
|
|
264
|
+
if (to === undefined || length === 0) continue;
|
|
265
|
+
const step = new Vector2().subVectors(to, from).multiplyScalar(overdraw / length);
|
|
266
|
+
to.add(step);
|
|
267
|
+
from.sub(step);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A face's paint. An opaque one is also outlined in its colour, a little (the width is set
|
|
274
|
+
* once, on the group of every path), so that the pieces a face is cut into show no seams.
|
|
275
|
+
*/
|
|
276
|
+
const facetPaint = (fill: string, opacity: number): string =>
|
|
277
|
+
opacity < 1
|
|
278
|
+
? `fill="${fill}" fill-opacity="${String(Number(opacity.toFixed(2)))}"`
|
|
279
|
+
: `fill="${fill}" stroke="${fill}"`;
|
|
280
|
+
|
|
281
|
+
/** Where an object's bounds are centred in the world. */
|
|
282
|
+
const centreOf = (object: Object3D): Vector3 => {
|
|
283
|
+
if (!(object instanceof Mesh)) return new Vector3().setFromMatrixPosition(object.matrixWorld);
|
|
284
|
+
const geometry = object.geometry;
|
|
285
|
+
if (geometry.boundingSphere === null) geometry.computeBoundingSphere();
|
|
286
|
+
return (geometry.boundingSphere?.center.clone() ?? new Vector3()).applyMatrix4(
|
|
287
|
+
object.matrixWorld,
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const isDrawn = (object: Object3D): boolean =>
|
|
292
|
+
object instanceof Mesh ||
|
|
293
|
+
object instanceof Line ||
|
|
294
|
+
object instanceof Points ||
|
|
295
|
+
object instanceof Sprite;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Draws `description` as SVG text `width` by `height` pixels, at `at` seconds of scene time,
|
|
299
|
+
* or says why the description is invalid.
|
|
300
|
+
*/
|
|
301
|
+
export const previewSvg = (
|
|
302
|
+
description: SceneDescription,
|
|
303
|
+
options: PreviewOptions,
|
|
304
|
+
): PreviewResult => {
|
|
305
|
+
const { width, height } = options;
|
|
306
|
+
const graph = createSceneGraph({
|
|
307
|
+
...(options.store === undefined ? {} : { store: options.store }),
|
|
308
|
+
...(options.behaviours === undefined ? {} : { behaviours: options.behaviours }),
|
|
309
|
+
});
|
|
310
|
+
const applied = graph.reconcile(description);
|
|
311
|
+
if (applied._tag === 'Invalid') {
|
|
312
|
+
graph.dispose();
|
|
313
|
+
return applied;
|
|
314
|
+
}
|
|
315
|
+
graph.resize(width, height);
|
|
316
|
+
graph.tick(options.at ?? 0);
|
|
317
|
+
const { scene, camera } = graph;
|
|
318
|
+
scene.updateMatrixWorld();
|
|
319
|
+
camera.updateMatrixWorld();
|
|
320
|
+
|
|
321
|
+
const lights: Array<Light> = [];
|
|
322
|
+
const drawn: Array<Object3D> = [];
|
|
323
|
+
scene.traverseVisible((object) => {
|
|
324
|
+
if (object instanceof Light) lights.push(object);
|
|
325
|
+
else if (isDrawn(object)) drawn.push(object);
|
|
326
|
+
});
|
|
327
|
+
const colorSpace =
|
|
328
|
+
graph.renderSettings().colorSpace === 'linear' ? LinearSRGBColorSpace : SRGBColorSpace;
|
|
329
|
+
const painter: Painter = {
|
|
330
|
+
lights,
|
|
331
|
+
normalView: new Matrix3().getNormalMatrix(camera.matrixWorldInverse),
|
|
332
|
+
colorSpace,
|
|
333
|
+
};
|
|
334
|
+
const halfWidth = width / 2;
|
|
335
|
+
const halfHeight = height / 2;
|
|
336
|
+
const at = number(options.precision ?? 1);
|
|
337
|
+
const eye = new Vector3().setFromMatrixPosition(camera.matrixWorld);
|
|
338
|
+
const ahead = camera.getWorldDirection(new Vector3());
|
|
339
|
+
// Only what is past the near plane is drawn: the rest would project behind the eye.
|
|
340
|
+
const near: Plane = { normal: ahead, constant: ahead.dot(eye) + camera.near };
|
|
341
|
+
const xy = (x: number, y: number): string => `${at(x * halfWidth)},${at(-y * halfHeight)}`;
|
|
342
|
+
|
|
343
|
+
/** A face as lit, the part of it past the near plane; none for a material not drawn. */
|
|
344
|
+
function facetOf(
|
|
345
|
+
face: RenderableFace,
|
|
346
|
+
material: Material,
|
|
347
|
+
centre: Vector3,
|
|
348
|
+
opacity: number,
|
|
349
|
+
): Facet<Item> | undefined {
|
|
350
|
+
const color = faceColor(painter, face, material, centre);
|
|
351
|
+
if (color === undefined) return undefined;
|
|
352
|
+
const points = [face.v1, face.v2, face.v3].map((vertex) => vertex.positionWorld.clone());
|
|
353
|
+
const paint = facetPaint(styleOf(color, colorSpace), opacity);
|
|
354
|
+
return split(near, { _tag: 'Facet', points, item: { paint, d: '' } }).front;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Each object stands in alone, so that what it draws is known as one unit. What is not
|
|
358
|
+
// depth-tested (gizmos drawn over everything) is painted after the rest, by render
|
|
359
|
+
// order, as three draws it.
|
|
360
|
+
const projector = new Projector();
|
|
361
|
+
const holder = new Scene();
|
|
362
|
+
const units: Array<Unit<Item>> = [];
|
|
363
|
+
const over: Array<{ readonly renderOrder: number; readonly item: Facet<Item> | Mark<Item> }> = [];
|
|
364
|
+
const tinted: Array<Material> = [];
|
|
365
|
+
for (const object of drawn) {
|
|
366
|
+
for (const { standIn, tinted: copy } of standInsFor(object)) {
|
|
367
|
+
if (copy !== undefined) tinted.push(copy);
|
|
368
|
+
standIn.matrixAutoUpdate = false;
|
|
369
|
+
holder.add(standIn);
|
|
370
|
+
const projected = projector.projectScene(holder, camera, false, true);
|
|
371
|
+
holder.remove(standIn);
|
|
372
|
+
const centre = centreOf(standIn);
|
|
373
|
+
const facets: Array<Facet<Item>> = [];
|
|
374
|
+
const marks: Array<Mark<Item>> = [];
|
|
375
|
+
const keep = (material: Material, item: Facet<Item> | Mark<Item>): void => {
|
|
376
|
+
if (!material.depthTest) over.push({ renderOrder: standIn.renderOrder, item });
|
|
377
|
+
else if (item._tag === 'Facet') facets.push(item);
|
|
378
|
+
else marks.push(item);
|
|
379
|
+
};
|
|
380
|
+
for (const element of projected.elements) {
|
|
381
|
+
const material: Material | null = element.material;
|
|
382
|
+
if (material === null || material.opacity === 0) continue;
|
|
383
|
+
const opacity = opacityOf(material);
|
|
384
|
+
const faded = opacity < 1 ? ` fill-opacity="${at(opacity)}"` : '';
|
|
385
|
+
if (element instanceof RenderableFace) {
|
|
386
|
+
const seen = facetOf(element, material, centre, opacity);
|
|
387
|
+
if (seen === undefined) continue;
|
|
388
|
+
keep(material, seen);
|
|
389
|
+
} else if (element instanceof RenderableLine && material instanceof LineBasicMaterial) {
|
|
390
|
+
const a = element.v1.positionScreen;
|
|
391
|
+
const b = element.v2.positionScreen;
|
|
392
|
+
const dashes =
|
|
393
|
+
material instanceof LineDashedMaterial
|
|
394
|
+
? ` stroke-dasharray="${at(material.dashSize)},${at(material.gapSize)}"`
|
|
395
|
+
: '';
|
|
396
|
+
const stroked = opacity < 1 ? ` stroke-opacity="${at(opacity)}"` : '';
|
|
397
|
+
keep(material, {
|
|
398
|
+
_tag: 'Mark',
|
|
399
|
+
at: element.v1.positionWorld.clone().lerp(element.v2.positionWorld, 0.5),
|
|
400
|
+
item: {
|
|
401
|
+
paint: `fill="none" stroke="${styleOf(material.color, colorSpace)}"${stroked} stroke-width="${at(material.linewidth)}" stroke-linecap="${material.linecap}"${dashes}`,
|
|
402
|
+
d: `M${xy(a.x, a.y)}L${xy(b.x, b.y)}`,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
} else if (
|
|
406
|
+
element instanceof RenderableSprite &&
|
|
407
|
+
(material instanceof SpriteMaterial || material instanceof PointsMaterial)
|
|
408
|
+
) {
|
|
409
|
+
const size = material instanceof PointsMaterial ? material.size : 1;
|
|
410
|
+
const w = element.scale.x * halfWidth * size;
|
|
411
|
+
const h = element.scale.y * halfHeight * size;
|
|
412
|
+
const x = element.x * halfWidth;
|
|
413
|
+
const y = -element.y * halfHeight;
|
|
414
|
+
keep(material, {
|
|
415
|
+
_tag: 'Mark',
|
|
416
|
+
at: new Vector3(element.x, element.y, element.z).unproject(camera),
|
|
417
|
+
item: {
|
|
418
|
+
paint: `fill="${styleOf(material.color, colorSpace)}"${faded}`,
|
|
419
|
+
d: `M${at(x - w / 2)},${at(y - h / 2)}h${at(w)}v${at(h)}h${at(-w)}z`,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (facets.length + marks.length > 0) units.push(unitOf(facets, marks, eye));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const material of tinted) material.dispose();
|
|
428
|
+
graph.dispose();
|
|
429
|
+
|
|
430
|
+
const ordered = [
|
|
431
|
+
...paintOrder(units, eye),
|
|
432
|
+
...over.toSorted((a, b) => a.renderOrder - b.renderOrder).map(({ item }) => item),
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
// Consecutive shapes of one paint share a path, as `SVGRenderer.addPath` does.
|
|
436
|
+
const clip = new Box2(new Vector2(-halfWidth, -halfHeight), new Vector2(halfWidth, halfHeight));
|
|
437
|
+
const toPicture = (point: Vector3): Vector2 => {
|
|
438
|
+
const projectedPoint = point.clone().project(camera);
|
|
439
|
+
return new Vector2(projectedPoint.x * halfWidth, -projectedPoint.y * halfHeight);
|
|
440
|
+
};
|
|
441
|
+
/** A shape's outline on the picture, or none when it falls outside it. */
|
|
442
|
+
function outlineOf(shape: Facet<Item> | Mark<Item>): string | undefined {
|
|
443
|
+
if (shape._tag === 'Mark') return shape.item.d;
|
|
444
|
+
const corners = widened(shape.points.map(toPicture));
|
|
445
|
+
return new Box2().setFromPoints([...corners]).intersectsBox(clip)
|
|
446
|
+
? `M${corners.map((corner) => `${at(corner.x)},${at(corner.y)}`).join('L')}z`
|
|
447
|
+
: undefined;
|
|
448
|
+
}
|
|
449
|
+
const groups: Array<{ readonly paint: string; readonly outlines: Array<string> }> = [];
|
|
450
|
+
for (const shape of ordered) {
|
|
451
|
+
const outline = outlineOf(shape);
|
|
452
|
+
const last = groups.at(-1);
|
|
453
|
+
if (outline === undefined) continue;
|
|
454
|
+
if (last?.paint === shape.item.paint) last.outlines.push(outline);
|
|
455
|
+
else groups.push({ paint: shape.item.paint, outlines: [outline] });
|
|
456
|
+
}
|
|
457
|
+
const paths = groups.map(({ paint, outlines }) => `<path ${paint} d="${outlines.join('')}"/>`);
|
|
458
|
+
|
|
459
|
+
const background =
|
|
460
|
+
options.background === undefined || options.background === null
|
|
461
|
+
? ''
|
|
462
|
+
: `<rect x="${at(-halfWidth)}" y="${at(-halfHeight)}" width="${at(width)}" height="${at(height)}" fill="${styleOf(new Color(options.background), colorSpace)}"/>`;
|
|
463
|
+
return {
|
|
464
|
+
_tag: 'Preview',
|
|
465
|
+
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${at(width)}" height="${at(height)}" viewBox="${at(-halfWidth)} ${at(-halfHeight)} ${at(width)} ${at(height)}">${background}<g stroke-width="0.6" stroke-linejoin="round">${paths.join('')}</g></svg>`,
|
|
466
|
+
};
|
|
467
|
+
};
|
package/src/kernel/sceneGraph.ts
CHANGED
|
@@ -30,7 +30,12 @@ import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js';
|
|
|
30
30
|
|
|
31
31
|
import { clipRunning, clipTime } from './animation.ts';
|
|
32
32
|
import { writeRotation } from './behaviours.ts';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
type BodyCollision,
|
|
35
|
+
type BodyContactForce,
|
|
36
|
+
createBodyHost,
|
|
37
|
+
type PhysicsReached,
|
|
38
|
+
} from './bodies.ts';
|
|
34
39
|
import { type BodySource, instanceSources, nodeSource, placeObject } from './bodySources.ts';
|
|
35
40
|
import { orthographicCamera, renderSettings, scene as describeScene } from './build.ts';
|
|
36
41
|
import { type CameraMotion, createCameraMotion } from './cameraMotion.ts';
|
|
@@ -298,6 +303,8 @@ export type SceneGraph = {
|
|
|
298
303
|
readonly collisions: () => ReadonlyArray<BodyCollision>;
|
|
299
304
|
/** Contact forces over their bodies' thresholds since the last call. */
|
|
300
305
|
readonly contactForces: () => ReadonlyArray<BodyContactForce>;
|
|
306
|
+
/** Steps a world on `physics({ until })` has reached since the last call. */
|
|
307
|
+
readonly physicsReached: () => ReadonlyArray<PhysicsReached>;
|
|
301
308
|
/** How the scene is presented immersively, for the host; `null` if it never is. */
|
|
302
309
|
readonly xr: () => Xr | null;
|
|
303
310
|
/** Keys of the nodes whose bounds the frame loop reports. */
|
|
@@ -614,6 +621,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
614
621
|
const spinning = new Set<Entry>();
|
|
615
622
|
// Billboards: turned towards the camera each frame, after everything else has moved.
|
|
616
623
|
const facing = new Set<Entry>();
|
|
624
|
+
// Parents first, so a billboard inside a billboard reads this frame's parent turn.
|
|
625
|
+
const facingOrder: Array<Entry> = [];
|
|
617
626
|
const cameraTurn = new Quaternion();
|
|
618
627
|
const parentTurn = new Quaternion();
|
|
619
628
|
// Points that move by themselves (sparkles, stars), ticked in scene time while animating.
|
|
@@ -716,6 +725,24 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
716
725
|
if (lockZ) object.rotation.z = 0;
|
|
717
726
|
};
|
|
718
727
|
|
|
728
|
+
const depthOf = (object: Object3D): number => {
|
|
729
|
+
let depth = 0;
|
|
730
|
+
for (let parent = object.parent; parent !== null; parent = parent.parent) depth += 1;
|
|
731
|
+
return depth;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Faces every billboard, shallowest first: a nested one is turned against its parent's
|
|
736
|
+
* world rotation, so the parent must have turned this frame. Drei gets this from React
|
|
737
|
+
* running a parent's frame callback before its child's.
|
|
738
|
+
*/
|
|
739
|
+
const faceCameras = (): void => {
|
|
740
|
+
facingOrder.length = 0;
|
|
741
|
+
facing.forEach((entry) => facingOrder.push(entry));
|
|
742
|
+
facingOrder.sort((a, b) => depthOf(a.object) - depthOf(b.object));
|
|
743
|
+
facingOrder.forEach(faceCamera);
|
|
744
|
+
};
|
|
745
|
+
|
|
719
746
|
/** Where a trail follows its node: the world position, or with `local` its own. */
|
|
720
747
|
const trailTarget = (object: Object3D, local: boolean): Vector3 =>
|
|
721
748
|
local ? trailPlace.copy(object.position) : object.getWorldPosition(trailPlace);
|
|
@@ -1543,6 +1570,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
1543
1570
|
physics: null,
|
|
1544
1571
|
xr: null,
|
|
1545
1572
|
measure: [],
|
|
1573
|
+
assets: [],
|
|
1546
1574
|
render: parent.render,
|
|
1547
1575
|
resources: parent.resources,
|
|
1548
1576
|
children: texture.children,
|
|
@@ -1712,6 +1740,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
1712
1740
|
if ((physicsDescription?.debug ?? false) !== (next.physics?.debug ?? false)) invalidate();
|
|
1713
1741
|
physicsDescription = next.physics;
|
|
1714
1742
|
bodyHost.sync(next.physics, bodySources());
|
|
1743
|
+
// A world on `until` that owes steps needs frames, though nothing else may be moving.
|
|
1744
|
+
if (bodyHost.owesSteps()) invalidate();
|
|
1715
1745
|
// Read by the host when it presents the scene immersively: no operation, no frame.
|
|
1716
1746
|
xrDescription = next.xr;
|
|
1717
1747
|
// Measured after the next drawn frame; a new list needs one.
|
|
@@ -1749,7 +1779,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
1749
1779
|
// After the camera and everything a billboard may sit on have moved.
|
|
1750
1780
|
if (facing.size > 0) {
|
|
1751
1781
|
camera.getWorldQuaternion(cameraTurn);
|
|
1752
|
-
|
|
1782
|
+
faceCameras();
|
|
1753
1783
|
}
|
|
1754
1784
|
edged.forEach(refreshEdges);
|
|
1755
1785
|
// Reduced motion holds sparkles and stars where they are.
|
|
@@ -1825,6 +1855,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
1825
1855
|
contactShadows: () => contactDescription,
|
|
1826
1856
|
collisions: bodyHost.drainCollisions,
|
|
1827
1857
|
contactForces: bodyHost.drainContactForces,
|
|
1858
|
+
physicsReached: bodyHost.drainReached,
|
|
1828
1859
|
xr: () => xrDescription,
|
|
1829
1860
|
measured: () => measured,
|
|
1830
1861
|
labelLayout: (key) => {
|
|
@@ -1859,7 +1890,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
|
|
|
1859
1890
|
viewsMoving() ||
|
|
1860
1891
|
shaking() ||
|
|
1861
1892
|
sparkling.size > 0 ||
|
|
1862
|
-
bodyHost.needsFrame()))
|
|
1893
|
+
bodyHost.needsFrame())) ||
|
|
1894
|
+
bodyHost.owesSteps(),
|
|
1863
1895
|
drawn: () => {
|
|
1864
1896
|
dirty = false;
|
|
1865
1897
|
},
|
package/src/kernel/sceneIndex.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type DescriptionError =
|
|
|
32
32
|
| { readonly _tag: 'UnknownOverlayNode'; readonly key: string; readonly node: string }
|
|
33
33
|
| { readonly _tag: 'UnknownOverlayView'; readonly key: string; readonly view: string }
|
|
34
34
|
| { readonly _tag: 'UnknownMeasuredNode'; readonly key: string }
|
|
35
|
+
/** Two assets the scene asks for share an id. */
|
|
36
|
+
| { readonly _tag: 'DuplicateAssetId'; readonly id: string }
|
|
35
37
|
/** Two joints share a key. */
|
|
36
38
|
| { readonly _tag: 'DuplicateJointKey'; readonly key: string }
|
|
37
39
|
/** A joint names a node that is not in the scene. */
|
|
@@ -104,6 +106,12 @@ export const indexScene = (
|
|
|
104
106
|
if (duplicateView !== undefined) {
|
|
105
107
|
return { _tag: 'Invalid', error: { _tag: 'DuplicateViewKey', key: duplicateView } };
|
|
106
108
|
}
|
|
109
|
+
const duplicateAsset = firstDuplicateInstance(
|
|
110
|
+
description.assets.map((asset) => ({ key: asset.id })),
|
|
111
|
+
);
|
|
112
|
+
if (duplicateAsset !== undefined) {
|
|
113
|
+
return { _tag: 'Invalid', error: { _tag: 'DuplicateAssetId', id: duplicateAsset } };
|
|
114
|
+
}
|
|
107
115
|
const duplicateOverlay = firstDuplicateInstance(description.overlays);
|
|
108
116
|
if (duplicateOverlay !== undefined) {
|
|
109
117
|
return { _tag: 'Invalid', error: { _tag: 'DuplicateOverlayKey', key: duplicateOverlay } };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A timeline of physics steps, as data (ADR-014): inputs stamped with the step they apply
|
|
3
|
+
* at, and how far a world on `physics({ until })` may go. Peers (or a replay) that give
|
|
4
|
+
* the same inputs at the same steps to the same world get the same world, however their
|
|
5
|
+
* frames fell, because every change lands while the world waits at its step.
|
|
6
|
+
*
|
|
7
|
+
* The Model keeps a `Timeline`, `schedule`s inputs as they are agreed (a lockstep turn's,
|
|
8
|
+
* a recording's), moves the `frontier` as far as is known, `reach`es each step the world
|
|
9
|
+
* reports (`PhysicsReached`), and applies what `due` hands back. Its scene's physics is
|
|
10
|
+
* `physics({ until: Timeline.until(timeline) })`: the next step with inputs, or the
|
|
11
|
+
* frontier. Use it as a namespace: `Timeline.make()`.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** An input, and the step it applies at. */
|
|
17
|
+
export type Scheduled<I> = { readonly step: number; readonly input: I };
|
|
18
|
+
|
|
19
|
+
export type Timeline<I> = {
|
|
20
|
+
/** Every input, in the order they apply: by step, then as scheduled. */
|
|
21
|
+
readonly inputs: ReadonlyArray<Scheduled<I>>;
|
|
22
|
+
/** How many of `inputs` have been handed out by `due`. */
|
|
23
|
+
readonly applied: number;
|
|
24
|
+
/** The step the world last said it reached; `null` before it says any. */
|
|
25
|
+
readonly reached: number | null;
|
|
26
|
+
/** How far the world may go before more inputs could arrive. */
|
|
27
|
+
readonly frontier: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** An empty timeline, the world allowed as far as `frontier`. */
|
|
31
|
+
export const make = <I>(frontier = 0): Timeline<I> => ({
|
|
32
|
+
inputs: [],
|
|
33
|
+
applied: 0,
|
|
34
|
+
reached: null,
|
|
35
|
+
frontier,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `input` at `step`, after any already there. The step must be one the world has not
|
|
40
|
+
* passed: not before `until`. A lockstep session stamps a turn's inputs with that turn's
|
|
41
|
+
* step, which is the frontier until the turn applies.
|
|
42
|
+
*/
|
|
43
|
+
export const schedule = <I>(timeline: Timeline<I>, step: number, input: I): Timeline<I> => {
|
|
44
|
+
const at = timeline.inputs.findIndex((scheduled) => scheduled.step > step);
|
|
45
|
+
const index = at < 0 ? timeline.inputs.length : at;
|
|
46
|
+
return {
|
|
47
|
+
...timeline,
|
|
48
|
+
inputs: [...timeline.inputs.slice(0, index), { step, input }, ...timeline.inputs.slice(index)],
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The world may now go as far as `frontier` (a timeline's frontier never goes back). */
|
|
53
|
+
export const extend = <I>(timeline: Timeline<I>, frontier: number): Timeline<I> =>
|
|
54
|
+
frontier <= timeline.frontier ? timeline : { ...timeline, frontier };
|
|
55
|
+
|
|
56
|
+
/** The world said it reached `step`. */
|
|
57
|
+
export const reach = <I>(timeline: Timeline<I>, step: number): Timeline<I> =>
|
|
58
|
+
timeline.reached === step ? timeline : { ...timeline, reached: step };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The inputs for the step the world is at, to apply now, and the timeline without them.
|
|
62
|
+
* Applied while the world waits there, they land at that step.
|
|
63
|
+
*/
|
|
64
|
+
export const due = <I>(
|
|
65
|
+
timeline: Timeline<I>,
|
|
66
|
+
): { readonly timeline: Timeline<I>; readonly inputs: ReadonlyArray<I> } => {
|
|
67
|
+
const { reached } = timeline;
|
|
68
|
+
if (reached === null) return { timeline, inputs: [] };
|
|
69
|
+
let applied = timeline.applied;
|
|
70
|
+
while ((timeline.inputs[applied]?.step ?? Infinity) <= reached) applied += 1;
|
|
71
|
+
if (applied === timeline.applied) return { timeline, inputs: [] };
|
|
72
|
+
return {
|
|
73
|
+
timeline: { ...timeline, applied },
|
|
74
|
+
inputs: timeline.inputs.slice(timeline.applied, applied).map((scheduled) => scheduled.input),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where the world should stop next: at the next step with inputs not yet applied, or at
|
|
80
|
+
* the frontier. Give it as `physics({ until })`.
|
|
81
|
+
*/
|
|
82
|
+
export const until = <I>(timeline: Timeline<I>): number =>
|
|
83
|
+
Math.min(timeline.inputs[timeline.applied]?.step ?? Infinity, timeline.frontier);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The same inputs from the start, for a new world (a newcomer to a session, a replay):
|
|
87
|
+
* nothing applied or reached yet, the frontier where it was.
|
|
88
|
+
*/
|
|
89
|
+
export const restart = <I>(timeline: Timeline<I>): Timeline<I> => ({
|
|
90
|
+
...timeline,
|
|
91
|
+
applied: 0,
|
|
92
|
+
reached: null,
|
|
93
|
+
});
|