@es3d/es3d 1.0.0 → 1.0.1
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 +201 -201
- package/README.md +86 -86
- package/docs/SPEC.md +417 -417
- package/package.json +1 -1
- package/scripts/es3d-cli.mjs +377 -377
- package/src/cache/geometry-store.js +392 -392
- package/src/cache/mesh-bundle.js +123 -123
- package/src/cache/sha256.js +73 -73
- package/src/format/ast.js +238 -238
- package/src/format/colors.js +93 -93
- package/src/format/compact-parser.js +171 -171
- package/src/format/compact-tokenizer.js +165 -165
- package/src/format/errors.js +247 -247
- package/src/format/json.js +55 -55
- package/src/format/limits.js +14 -14
- package/src/format/parse.js +92 -92
- package/src/format/schema.js +617 -617
- package/src/format/serializer.js +333 -333
- package/src/format/validator.js +330 -330
- package/src/format/value-error.js +12 -12
- package/src/format/value-format.js +314 -314
- package/src/format/values.js +459 -459
- package/src/format/xml-parser.js +136 -136
- package/src/format/xml-tokenizer.js +237 -237
- package/src/geometry/contour.js +86 -86
- package/src/geometry/extrude.js +175 -175
- package/src/geometry/gear.js +85 -85
- package/src/geometry/geometry-cache.js +159 -159
- package/src/geometry/lod-error.js +178 -178
- package/src/geometry/mesh.js +458 -458
- package/src/geometry/primitives.js +299 -299
- package/src/geometry/registry.js +645 -645
- package/src/geometry/revolve.js +136 -136
- package/src/geometry/sweep.js +441 -441
- package/src/index.js +47 -47
- package/src/io/compression.js +67 -67
- package/src/io/convert.js +236 -236
- package/src/io/download.js +13 -13
- package/src/io/external-scene.js +127 -127
- package/src/io/file-loader.js +28 -28
- package/src/io/gltf.js +630 -630
- package/src/io/mesh-import.js +104 -104
- package/src/io/obj.js +352 -352
- package/src/io/step-brep.js +439 -439
- package/src/io/step-exact.js +1149 -1149
- package/src/io/step.js +1418 -1418
- package/src/io/stl.js +193 -193
- package/src/io/svg-import.js +787 -787
- package/src/material/materials.js +154 -154
- package/src/math/mat4.js +475 -475
- package/src/math/random.js +30 -30
- package/src/math/trig.js +65 -65
- package/src/math/vec3.js +81 -81
- package/src/optimize/optimizer.js +579 -579
- package/src/path/path-flatten.js +322 -322
- package/src/path/path-parser.js +332 -332
- package/src/path/shapes.js +129 -129
- package/src/path/triangulate.js +517 -517
- package/src/pipeline.js +74 -74
- package/src/render/camera.js +168 -168
- package/src/render/frame-builder.js +362 -362
- package/src/render/geometry-pool.js +149 -149
- package/src/render/lighting.js +149 -149
- package/src/render/orbit-controls.js +237 -237
- package/src/render/renderer.js +258 -249
- package/src/render/scroll-timeline.js +104 -104
- package/src/render/shaders/glsl.js +285 -285
- package/src/render/shaders/wgsl.js +252 -252
- package/src/render/stage.js +364 -352
- package/src/render/texture-cache.js +40 -40
- package/src/render/textures.js +223 -223
- package/src/render/webgl2/webgl2-backend.js +586 -532
- package/src/render/webgpu/webgpu-backend.js +482 -480
- package/src/scene/flatten.js +754 -754
- package/src/scene/picking.js +161 -161
- package/src/scene/scene-graph.js +320 -320
- package/src/transform/transform.js +211 -211
- package/src/wasm/kernels-binary.js +3 -3
- package/src/wasm/kernels.js +385 -385
package/src/pipeline.js
CHANGED
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
// The ES3D processing pipeline used by the application, the benchmark, the CLI
|
|
2
|
-
// and the tests:
|
|
3
|
-
//
|
|
4
|
-
// text/binary --parse--> AST --validate--> typed AST --build--> scene graph
|
|
5
|
-
// --flatten--> RenderScene (instance table) --lazy--> meshes
|
|
6
|
-
//
|
|
7
|
-
// Each stage is timed so the benchmark and the statistics panel can report
|
|
8
|
-
// where time is spent.
|
|
9
|
-
|
|
10
|
-
import { parse } from './format/parse.js';
|
|
11
|
-
import { Diagnostic } from './format/errors.js';
|
|
12
|
-
import { buildSceneGraph } from './scene/scene-graph.js';
|
|
13
|
-
import { flattenScene } from './scene/flatten.js';
|
|
14
|
-
import { GeometryCache } from './geometry/geometry-cache.js';
|
|
15
|
-
|
|
16
|
-
export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
17
|
-
|
|
18
|
-
/** Convert an element-attached diagnostic into a located Diagnostic. */
|
|
19
|
-
export function elementDiagnostic(doc, d) {
|
|
20
|
-
const el = d.element;
|
|
21
|
-
const diag = new Diagnostic({
|
|
22
|
-
severity: d.severity,
|
|
23
|
-
phase: d.phase || 'geometry',
|
|
24
|
-
code: d.code,
|
|
25
|
-
message: el ? `<${el.name}${el.values && el.values.id ? ` id="${el.values.id}"` : ''}>: ${d.message}` : d.message,
|
|
26
|
-
offset: el && el.nameStart >= 0 ? el.nameStart : -1,
|
|
27
|
-
length: el && el.nameStart >= 0 ? el.nameEnd - el.nameStart : 0,
|
|
28
|
-
uid: el ? el.uid : 0,
|
|
29
|
-
});
|
|
30
|
-
diag.locate(doc && doc.lineIndex);
|
|
31
|
-
return diag;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Parse, validate, build and flatten a document.
|
|
36
|
-
* @param {string|ArrayBuffer|Uint8Array} input
|
|
37
|
-
* @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
|
|
38
|
-
*/
|
|
39
|
-
export function compile(input, opts = {}) {
|
|
40
|
-
const t0 = now();
|
|
41
|
-
const doc = parse(input);
|
|
42
|
-
const t1 = now();
|
|
43
|
-
const timings = { parse: t1 - t0, build: 0, flatten: 0 };
|
|
44
|
-
if (!doc.ok) {
|
|
45
|
-
return { ok: false, doc, graph: null, scene: null, cache: opts.cache || null, diagnostics: doc.diagnostics, timings };
|
|
46
|
-
}
|
|
47
|
-
const cache = opts.cache || new GeometryCache();
|
|
48
|
-
const graph = buildSceneGraph(doc, cache, opts);
|
|
49
|
-
const t2 = now();
|
|
50
|
-
const scene = flattenScene(graph, { time: opts.time || 0, progress: opts.progress || 0 });
|
|
51
|
-
const t3 = now();
|
|
52
|
-
timings.build = t2 - t1;
|
|
53
|
-
timings.flatten = t3 - t2;
|
|
54
|
-
const extra = graph.diagnostics.map((d) => elementDiagnostic(doc, d));
|
|
55
|
-
const diagnostics = [...doc.diagnostics, ...extra];
|
|
56
|
-
const ok = !diagnostics.some((d) => d.severity === 'error');
|
|
57
|
-
return { ok, doc, graph, scene, cache, diagnostics, timings };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Generate every mesh needed for LOD 0 of a compiled scene (benchmarks, exports). */
|
|
61
|
-
export function generateGeometry(result, lod = 0) {
|
|
62
|
-
const t0 = now();
|
|
63
|
-
const { scene, cache } = result;
|
|
64
|
-
let triangles = 0;
|
|
65
|
-
let vertices = 0;
|
|
66
|
-
for (const entry of scene.geometries) {
|
|
67
|
-
const m = cache.mesh(entry, lod);
|
|
68
|
-
vertices += m.vertexCount;
|
|
69
|
-
triangles += m.kind === 'triangles' ? m.primitiveCount : 0;
|
|
70
|
-
}
|
|
71
|
-
return { ms: now() - t0, uniqueVertices: vertices, uniqueTriangles: triangles };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export { GeometryCache };
|
|
1
|
+
// The ES3D processing pipeline used by the application, the benchmark, the CLI
|
|
2
|
+
// and the tests:
|
|
3
|
+
//
|
|
4
|
+
// text/binary --parse--> AST --validate--> typed AST --build--> scene graph
|
|
5
|
+
// --flatten--> RenderScene (instance table) --lazy--> meshes
|
|
6
|
+
//
|
|
7
|
+
// Each stage is timed so the benchmark and the statistics panel can report
|
|
8
|
+
// where time is spent.
|
|
9
|
+
|
|
10
|
+
import { parse } from './format/parse.js';
|
|
11
|
+
import { Diagnostic } from './format/errors.js';
|
|
12
|
+
import { buildSceneGraph } from './scene/scene-graph.js';
|
|
13
|
+
import { flattenScene } from './scene/flatten.js';
|
|
14
|
+
import { GeometryCache } from './geometry/geometry-cache.js';
|
|
15
|
+
|
|
16
|
+
export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
17
|
+
|
|
18
|
+
/** Convert an element-attached diagnostic into a located Diagnostic. */
|
|
19
|
+
export function elementDiagnostic(doc, d) {
|
|
20
|
+
const el = d.element;
|
|
21
|
+
const diag = new Diagnostic({
|
|
22
|
+
severity: d.severity,
|
|
23
|
+
phase: d.phase || 'geometry',
|
|
24
|
+
code: d.code,
|
|
25
|
+
message: el ? `<${el.name}${el.values && el.values.id ? ` id="${el.values.id}"` : ''}>: ${d.message}` : d.message,
|
|
26
|
+
offset: el && el.nameStart >= 0 ? el.nameStart : -1,
|
|
27
|
+
length: el && el.nameStart >= 0 ? el.nameEnd - el.nameStart : 0,
|
|
28
|
+
uid: el ? el.uid : 0,
|
|
29
|
+
});
|
|
30
|
+
diag.locate(doc && doc.lineIndex);
|
|
31
|
+
return diag;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse, validate, build and flatten a document.
|
|
36
|
+
* @param {string|ArrayBuffer|Uint8Array} input
|
|
37
|
+
* @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
|
|
38
|
+
*/
|
|
39
|
+
export function compile(input, opts = {}) {
|
|
40
|
+
const t0 = now();
|
|
41
|
+
const doc = parse(input);
|
|
42
|
+
const t1 = now();
|
|
43
|
+
const timings = { parse: t1 - t0, build: 0, flatten: 0 };
|
|
44
|
+
if (!doc.ok) {
|
|
45
|
+
return { ok: false, doc, graph: null, scene: null, cache: opts.cache || null, diagnostics: doc.diagnostics, timings };
|
|
46
|
+
}
|
|
47
|
+
const cache = opts.cache || new GeometryCache();
|
|
48
|
+
const graph = buildSceneGraph(doc, cache, opts);
|
|
49
|
+
const t2 = now();
|
|
50
|
+
const scene = flattenScene(graph, { time: opts.time || 0, progress: opts.progress || 0 });
|
|
51
|
+
const t3 = now();
|
|
52
|
+
timings.build = t2 - t1;
|
|
53
|
+
timings.flatten = t3 - t2;
|
|
54
|
+
const extra = graph.diagnostics.map((d) => elementDiagnostic(doc, d));
|
|
55
|
+
const diagnostics = [...doc.diagnostics, ...extra];
|
|
56
|
+
const ok = !diagnostics.some((d) => d.severity === 'error');
|
|
57
|
+
return { ok, doc, graph, scene, cache, diagnostics, timings };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Generate every mesh needed for LOD 0 of a compiled scene (benchmarks, exports). */
|
|
61
|
+
export function generateGeometry(result, lod = 0) {
|
|
62
|
+
const t0 = now();
|
|
63
|
+
const { scene, cache } = result;
|
|
64
|
+
let triangles = 0;
|
|
65
|
+
let vertices = 0;
|
|
66
|
+
for (const entry of scene.geometries) {
|
|
67
|
+
const m = cache.mesh(entry, lod);
|
|
68
|
+
vertices += m.vertexCount;
|
|
69
|
+
triangles += m.kind === 'triangles' ? m.primitiveCount : 0;
|
|
70
|
+
}
|
|
71
|
+
return { ms: now() - t0, uniqueVertices: vertices, uniqueTriangles: triangles };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { GeometryCache };
|
package/src/render/camera.js
CHANGED
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
// Camera model shared by both GPU backends.
|
|
2
|
-
|
|
3
|
-
import * as mat4 from '../math/mat4.js';
|
|
4
|
-
|
|
5
|
-
export class Camera {
|
|
6
|
-
constructor() {
|
|
7
|
-
this.type = 'perspective';
|
|
8
|
-
this.position = [6, 4, 8];
|
|
9
|
-
this.target = [0, 0, 0];
|
|
10
|
-
this.up = [0, 1, 0];
|
|
11
|
-
this.fov = 45; // vertical, degrees
|
|
12
|
-
this.orthoHeight = 10;
|
|
13
|
-
this.near = null; // null = automatic from scene bounds
|
|
14
|
-
this.far = null;
|
|
15
|
-
// fov (or orthoHeight) is the vertical extent at this reference aspect ratio;
|
|
16
|
-
// fit says how other aspect ratios are derived from it (see verticalFov)
|
|
17
|
-
this.refAspect = 1;
|
|
18
|
-
this.fit = 'contain';
|
|
19
|
-
this.view = mat4.create();
|
|
20
|
-
this.proj = mat4.create();
|
|
21
|
-
this.viewProj = mat4.create();
|
|
22
|
-
this.invViewProj = mat4.create();
|
|
23
|
-
this.currentNear = 0.1;
|
|
24
|
-
this.currentFar = 1000;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Configure from a camera description produced by the flattener. */
|
|
28
|
-
fromSpec(c) {
|
|
29
|
-
this.type = c.type || 'perspective';
|
|
30
|
-
this.position = c.position.slice();
|
|
31
|
-
this.target = c.target.slice();
|
|
32
|
-
this.up = c.up.slice();
|
|
33
|
-
this.fov = c.fov || 45;
|
|
34
|
-
this.orthoHeight = c.height || 10;
|
|
35
|
-
this.near = c.near || null;
|
|
36
|
-
this.far = c.far || null;
|
|
37
|
-
this.refAspect = c.aspect || 1;
|
|
38
|
-
this.fit = c.fit || 'contain';
|
|
39
|
-
return this;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
clone() {
|
|
43
|
-
const c = new Camera();
|
|
44
|
-
c.type = this.type;
|
|
45
|
-
c.position = this.position.slice();
|
|
46
|
-
c.target = this.target.slice();
|
|
47
|
-
c.up = this.up.slice();
|
|
48
|
-
c.fov = this.fov;
|
|
49
|
-
c.orthoHeight = this.orthoHeight;
|
|
50
|
-
c.near = this.near;
|
|
51
|
-
c.far = this.far;
|
|
52
|
-
c.refAspect = this.refAspect;
|
|
53
|
-
c.fit = this.fit;
|
|
54
|
-
return c;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Factor that turns the reference vertical extent into the one for a viewport
|
|
59
|
-
* aspect ratio. The reference view is refAspect wide and 1 high:
|
|
60
|
-
* contain shows all of it (like SVG's "meet"), cover fills the viewport with
|
|
61
|
-
* it ("slice"), width keeps its width and height keeps its height.
|
|
62
|
-
*/
|
|
63
|
-
extentScale(aspect) {
|
|
64
|
-
const A = this.refAspect > 0 ? this.refAspect : 1;
|
|
65
|
-
const keepWidth = A / aspect; // vertical extent that keeps the reference width
|
|
66
|
-
switch (this.fit) {
|
|
67
|
-
case 'cover':
|
|
68
|
-
return Math.min(1, keepWidth);
|
|
69
|
-
case 'width':
|
|
70
|
-
return keepWidth;
|
|
71
|
-
case 'height':
|
|
72
|
-
return 1;
|
|
73
|
-
default:
|
|
74
|
-
return Math.max(1, keepWidth);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
get distance() {
|
|
79
|
-
return Math.hypot(this.position[0] - this.target[0], this.position[1] - this.target[1], this.position[2] - this.target[2]);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Frame an axis aligned bounding box. */
|
|
83
|
-
frame(bounds, dir = [0.62, 0.5, 0.75]) {
|
|
84
|
-
const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
|
|
85
|
-
const r = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
|
|
86
|
-
const l = Math.hypot(...dir);
|
|
87
|
-
const d = this.type === 'orthographic' ? r * 3 : (r / Math.sin(((this.fov / 2) * Math.PI) / 180)) * 1.05;
|
|
88
|
-
this.target = c;
|
|
89
|
-
this.position = [c[0] + (dir[0] / l) * d, c[1] + (dir[1] / l) * d, c[2] + (dir[2] / l) * d];
|
|
90
|
-
this.up = [0, 1, 0];
|
|
91
|
-
this.orthoHeight = r * 2.2;
|
|
92
|
-
// the distance above assumes fov applies to the shorter viewport side
|
|
93
|
-
this.refAspect = 1;
|
|
94
|
-
this.fit = 'contain';
|
|
95
|
-
return this;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Update view / projection matrices.
|
|
100
|
-
* @param {number} aspect
|
|
101
|
-
* @param {boolean} zeroToOne WebGPU depth range
|
|
102
|
-
* @param {{min:number[],max:number[]}} [bounds] scene bounds for automatic near/far
|
|
103
|
-
*/
|
|
104
|
-
update(aspect, zeroToOne, bounds) {
|
|
105
|
-
mat4.lookAt(this.view, this.position, this.target, this.up);
|
|
106
|
-
let near = this.near;
|
|
107
|
-
let far = this.far;
|
|
108
|
-
if (near === null || far === null) {
|
|
109
|
-
let autoNear = 0.05;
|
|
110
|
-
let autoFar = 2000;
|
|
111
|
-
if (bounds) {
|
|
112
|
-
const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
|
|
113
|
-
const R = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
|
|
114
|
-
const d = Math.hypot(this.position[0] - c[0], this.position[1] - c[1], this.position[2] - c[2]);
|
|
115
|
-
autoFar = (d + R) * 1.5 + 1e-3;
|
|
116
|
-
// outside the bounds the near plane can sit just before the scene;
|
|
117
|
-
// inside it is a small fraction of the scene size
|
|
118
|
-
autoNear = Math.max((d - R) * 0.5, R * 0.002, autoFar / 50000, 1e-4);
|
|
119
|
-
}
|
|
120
|
-
if (near === null) near = autoNear;
|
|
121
|
-
if (far === null) far = Math.max(autoFar, near * 10);
|
|
122
|
-
}
|
|
123
|
-
this.currentNear = near;
|
|
124
|
-
this.currentFar = far;
|
|
125
|
-
// Like SVG's preserveAspectRatio, the framed view (fov or the orthographic
|
|
126
|
-
// height at refAspect) is fitted to the viewport, so what the author framed
|
|
127
|
-
// stays framed on every screen shape. The default (contain, aspect 1)
|
|
128
|
-
// applies fov to the shorter side.
|
|
129
|
-
if (this.type === 'orthographic') {
|
|
130
|
-
const h = (this.orthoHeight * this.extentScale(aspect)) / 2;
|
|
131
|
-
const w = h * aspect;
|
|
132
|
-
mat4.ortho(this.proj, -w, w, -h, h, this.near === null ? -far : near, far, zeroToOne);
|
|
133
|
-
} else {
|
|
134
|
-
mat4.perspective(this.proj, this.verticalFov(aspect), aspect, near, far, zeroToOne);
|
|
135
|
-
}
|
|
136
|
-
this.aspect = aspect;
|
|
137
|
-
mat4.multiply(this.viewProj, this.proj, this.view);
|
|
138
|
-
mat4.invert(this.invViewProj, this.viewProj);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Vertical field of view in radians for a viewport aspect ratio. */
|
|
142
|
-
verticalFov(aspect) {
|
|
143
|
-
const f = (this.fov * Math.PI) / 180;
|
|
144
|
-
const s = this.extentScale(aspect);
|
|
145
|
-
return s === 1 ? f : 2 * Math.atan(Math.tan(f / 2) * s);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** World-space ray through a pixel (x, y in CSS pixels from the top-left). */
|
|
149
|
-
ray(x, y, width, height, zeroToOne = false) {
|
|
150
|
-
const nx = (x / width) * 2 - 1;
|
|
151
|
-
const ny = 1 - (y / height) * 2;
|
|
152
|
-
const z0 = zeroToOne ? 0 : -1;
|
|
153
|
-
const a = [0, 0, 0];
|
|
154
|
-
const b = [0, 0, 0];
|
|
155
|
-
mat4.transformPoint(a, this.invViewProj, nx, ny, z0);
|
|
156
|
-
mat4.transformPoint(b, this.invViewProj, nx, ny, 1);
|
|
157
|
-
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
158
|
-
const l = Math.hypot(d[0], d[1], d[2]) || 1;
|
|
159
|
-
return { origin: a, dir: [d[0] / l, d[1] / l, d[2] / l] };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Pixels per world unit at a distance (used for level-of-detail selection). */
|
|
163
|
-
pixelsPerUnit(distance, viewportHeight) {
|
|
164
|
-
const aspect = this.aspect || 1;
|
|
165
|
-
if (this.type === 'orthographic') return viewportHeight / (this.orthoHeight * this.extentScale(aspect));
|
|
166
|
-
return viewportHeight / (2 * Math.max(distance, 1e-6) * Math.tan(this.verticalFov(aspect) / 2));
|
|
167
|
-
}
|
|
168
|
-
}
|
|
1
|
+
// Camera model shared by both GPU backends.
|
|
2
|
+
|
|
3
|
+
import * as mat4 from '../math/mat4.js';
|
|
4
|
+
|
|
5
|
+
export class Camera {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.type = 'perspective';
|
|
8
|
+
this.position = [6, 4, 8];
|
|
9
|
+
this.target = [0, 0, 0];
|
|
10
|
+
this.up = [0, 1, 0];
|
|
11
|
+
this.fov = 45; // vertical, degrees
|
|
12
|
+
this.orthoHeight = 10;
|
|
13
|
+
this.near = null; // null = automatic from scene bounds
|
|
14
|
+
this.far = null;
|
|
15
|
+
// fov (or orthoHeight) is the vertical extent at this reference aspect ratio;
|
|
16
|
+
// fit says how other aspect ratios are derived from it (see verticalFov)
|
|
17
|
+
this.refAspect = 1;
|
|
18
|
+
this.fit = 'contain';
|
|
19
|
+
this.view = mat4.create();
|
|
20
|
+
this.proj = mat4.create();
|
|
21
|
+
this.viewProj = mat4.create();
|
|
22
|
+
this.invViewProj = mat4.create();
|
|
23
|
+
this.currentNear = 0.1;
|
|
24
|
+
this.currentFar = 1000;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Configure from a camera description produced by the flattener. */
|
|
28
|
+
fromSpec(c) {
|
|
29
|
+
this.type = c.type || 'perspective';
|
|
30
|
+
this.position = c.position.slice();
|
|
31
|
+
this.target = c.target.slice();
|
|
32
|
+
this.up = c.up.slice();
|
|
33
|
+
this.fov = c.fov || 45;
|
|
34
|
+
this.orthoHeight = c.height || 10;
|
|
35
|
+
this.near = c.near || null;
|
|
36
|
+
this.far = c.far || null;
|
|
37
|
+
this.refAspect = c.aspect || 1;
|
|
38
|
+
this.fit = c.fit || 'contain';
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clone() {
|
|
43
|
+
const c = new Camera();
|
|
44
|
+
c.type = this.type;
|
|
45
|
+
c.position = this.position.slice();
|
|
46
|
+
c.target = this.target.slice();
|
|
47
|
+
c.up = this.up.slice();
|
|
48
|
+
c.fov = this.fov;
|
|
49
|
+
c.orthoHeight = this.orthoHeight;
|
|
50
|
+
c.near = this.near;
|
|
51
|
+
c.far = this.far;
|
|
52
|
+
c.refAspect = this.refAspect;
|
|
53
|
+
c.fit = this.fit;
|
|
54
|
+
return c;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Factor that turns the reference vertical extent into the one for a viewport
|
|
59
|
+
* aspect ratio. The reference view is refAspect wide and 1 high:
|
|
60
|
+
* contain shows all of it (like SVG's "meet"), cover fills the viewport with
|
|
61
|
+
* it ("slice"), width keeps its width and height keeps its height.
|
|
62
|
+
*/
|
|
63
|
+
extentScale(aspect) {
|
|
64
|
+
const A = this.refAspect > 0 ? this.refAspect : 1;
|
|
65
|
+
const keepWidth = A / aspect; // vertical extent that keeps the reference width
|
|
66
|
+
switch (this.fit) {
|
|
67
|
+
case 'cover':
|
|
68
|
+
return Math.min(1, keepWidth);
|
|
69
|
+
case 'width':
|
|
70
|
+
return keepWidth;
|
|
71
|
+
case 'height':
|
|
72
|
+
return 1;
|
|
73
|
+
default:
|
|
74
|
+
return Math.max(1, keepWidth);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get distance() {
|
|
79
|
+
return Math.hypot(this.position[0] - this.target[0], this.position[1] - this.target[1], this.position[2] - this.target[2]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Frame an axis aligned bounding box. */
|
|
83
|
+
frame(bounds, dir = [0.62, 0.5, 0.75]) {
|
|
84
|
+
const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
|
|
85
|
+
const r = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
|
|
86
|
+
const l = Math.hypot(...dir);
|
|
87
|
+
const d = this.type === 'orthographic' ? r * 3 : (r / Math.sin(((this.fov / 2) * Math.PI) / 180)) * 1.05;
|
|
88
|
+
this.target = c;
|
|
89
|
+
this.position = [c[0] + (dir[0] / l) * d, c[1] + (dir[1] / l) * d, c[2] + (dir[2] / l) * d];
|
|
90
|
+
this.up = [0, 1, 0];
|
|
91
|
+
this.orthoHeight = r * 2.2;
|
|
92
|
+
// the distance above assumes fov applies to the shorter viewport side
|
|
93
|
+
this.refAspect = 1;
|
|
94
|
+
this.fit = 'contain';
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update view / projection matrices.
|
|
100
|
+
* @param {number} aspect
|
|
101
|
+
* @param {boolean} zeroToOne WebGPU depth range
|
|
102
|
+
* @param {{min:number[],max:number[]}} [bounds] scene bounds for automatic near/far
|
|
103
|
+
*/
|
|
104
|
+
update(aspect, zeroToOne, bounds) {
|
|
105
|
+
mat4.lookAt(this.view, this.position, this.target, this.up);
|
|
106
|
+
let near = this.near;
|
|
107
|
+
let far = this.far;
|
|
108
|
+
if (near === null || far === null) {
|
|
109
|
+
let autoNear = 0.05;
|
|
110
|
+
let autoFar = 2000;
|
|
111
|
+
if (bounds) {
|
|
112
|
+
const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
|
|
113
|
+
const R = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
|
|
114
|
+
const d = Math.hypot(this.position[0] - c[0], this.position[1] - c[1], this.position[2] - c[2]);
|
|
115
|
+
autoFar = (d + R) * 1.5 + 1e-3;
|
|
116
|
+
// outside the bounds the near plane can sit just before the scene;
|
|
117
|
+
// inside it is a small fraction of the scene size
|
|
118
|
+
autoNear = Math.max((d - R) * 0.5, R * 0.002, autoFar / 50000, 1e-4);
|
|
119
|
+
}
|
|
120
|
+
if (near === null) near = autoNear;
|
|
121
|
+
if (far === null) far = Math.max(autoFar, near * 10);
|
|
122
|
+
}
|
|
123
|
+
this.currentNear = near;
|
|
124
|
+
this.currentFar = far;
|
|
125
|
+
// Like SVG's preserveAspectRatio, the framed view (fov or the orthographic
|
|
126
|
+
// height at refAspect) is fitted to the viewport, so what the author framed
|
|
127
|
+
// stays framed on every screen shape. The default (contain, aspect 1)
|
|
128
|
+
// applies fov to the shorter side.
|
|
129
|
+
if (this.type === 'orthographic') {
|
|
130
|
+
const h = (this.orthoHeight * this.extentScale(aspect)) / 2;
|
|
131
|
+
const w = h * aspect;
|
|
132
|
+
mat4.ortho(this.proj, -w, w, -h, h, this.near === null ? -far : near, far, zeroToOne);
|
|
133
|
+
} else {
|
|
134
|
+
mat4.perspective(this.proj, this.verticalFov(aspect), aspect, near, far, zeroToOne);
|
|
135
|
+
}
|
|
136
|
+
this.aspect = aspect;
|
|
137
|
+
mat4.multiply(this.viewProj, this.proj, this.view);
|
|
138
|
+
mat4.invert(this.invViewProj, this.viewProj);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Vertical field of view in radians for a viewport aspect ratio. */
|
|
142
|
+
verticalFov(aspect) {
|
|
143
|
+
const f = (this.fov * Math.PI) / 180;
|
|
144
|
+
const s = this.extentScale(aspect);
|
|
145
|
+
return s === 1 ? f : 2 * Math.atan(Math.tan(f / 2) * s);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** World-space ray through a pixel (x, y in CSS pixels from the top-left). */
|
|
149
|
+
ray(x, y, width, height, zeroToOne = false) {
|
|
150
|
+
const nx = (x / width) * 2 - 1;
|
|
151
|
+
const ny = 1 - (y / height) * 2;
|
|
152
|
+
const z0 = zeroToOne ? 0 : -1;
|
|
153
|
+
const a = [0, 0, 0];
|
|
154
|
+
const b = [0, 0, 0];
|
|
155
|
+
mat4.transformPoint(a, this.invViewProj, nx, ny, z0);
|
|
156
|
+
mat4.transformPoint(b, this.invViewProj, nx, ny, 1);
|
|
157
|
+
const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
158
|
+
const l = Math.hypot(d[0], d[1], d[2]) || 1;
|
|
159
|
+
return { origin: a, dir: [d[0] / l, d[1] / l, d[2] / l] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Pixels per world unit at a distance (used for level-of-detail selection). */
|
|
163
|
+
pixelsPerUnit(distance, viewportHeight) {
|
|
164
|
+
const aspect = this.aspect || 1;
|
|
165
|
+
if (this.type === 'orthographic') return viewportHeight / (this.orthoHeight * this.extentScale(aspect));
|
|
166
|
+
return viewportHeight / (2 * Math.max(distance, 1e-6) * Math.tan(this.verticalFov(aspect) / 2));
|
|
167
|
+
}
|
|
168
|
+
}
|