abstract-3d 4.0.0-beta.0 → 4.0.0-beta.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/README.md +2 -2
- package/lib/renderers/react/react.d.ts.map +1 -1
- package/lib/renderers/react/react.js +4 -3
- package/lib/renderers/react/react.js.map +1 -1
- package/lib/renderers/three/appearance/appearance.d.ts.map +1 -1
- package/lib/renderers/three/appearance/appearance.js +4 -1
- package/lib/renderers/three/appearance/appearance.js.map +1 -1
- package/lib/renderers/three/build/build.js +7 -3
- package/lib/renderers/three/build/build.js.map +1 -1
- package/lib/renderers/three/build/materials.d.ts +7 -3
- package/lib/renderers/three/build/materials.d.ts.map +1 -1
- package/lib/renderers/three/build/materials.js +10 -5
- package/lib/renderers/three/build/materials.js.map +1 -1
- package/lib/renderers/three/quality/quality.d.ts +5 -24
- package/lib/renderers/three/quality/quality.d.ts.map +1 -1
- package/lib/renderers/three/quality/quality.js +8 -44
- package/lib/renderers/three/quality/quality.js.map +1 -1
- package/lib/renderers/three/shadow/shadow.d.ts +15 -0
- package/lib/renderers/three/shadow/shadow.d.ts.map +1 -0
- package/lib/renderers/three/shadow/shadow.js +77 -0
- package/lib/renderers/three/shadow/shadow.js.map +1 -0
- package/lib/renderers/three/viewer/options.d.ts +7 -3
- package/lib/renderers/three/viewer/options.d.ts.map +1 -1
- package/lib/renderers/three/viewer/options.js +2 -0
- package/lib/renderers/three/viewer/options.js.map +1 -1
- package/lib/renderers/three/viewer/viewer.d.ts +3 -2
- package/lib/renderers/three/viewer/viewer.d.ts.map +1 -1
- package/lib/renderers/three/viewer/viewer.js +33 -25
- package/lib/renderers/three/viewer/viewer.js.map +1 -1
- package/package.json +1 -3
- package/src/renderers/ifc/ifc.test.ts +7 -5
- package/src/renderers/react/react.tsx +5 -4
- package/src/renderers/three/appearance/appearance.test.ts +14 -4
- package/src/renderers/three/appearance/appearance.ts +4 -1
- package/src/renderers/three/build/build.test.ts +18 -10
- package/src/renderers/three/build/build.ts +6 -3
- package/src/renderers/three/build/materials.ts +14 -8
- package/src/renderers/three/compile/compile.test.ts +32 -19
- package/src/renderers/three/quality/quality.test.ts +11 -4
- package/src/renderers/three/quality/quality.ts +7 -66
- package/src/renderers/three/shadow/shadow.ts +111 -0
- package/src/renderers/three/viewer/options.ts +9 -3
- package/src/renderers/three/viewer/viewer.ts +35 -28
- package/src/custom_typings/n8ao.d.ts +0 -11
|
@@ -1,43 +1,56 @@
|
|
|
1
1
|
import { Matrix4, Vector3 } from "three";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assert, describe, expect, it } from "vitest";
|
|
3
3
|
import type { Group, Scene } from "../../../abstract-3d.js";
|
|
4
4
|
import { alignedDimension, box, group, line, sphere, text, vec3, vec3Zero } from "../../../abstract-3d.js";
|
|
5
|
+
import type { CompiledScene, Item } from "./compile.js";
|
|
5
6
|
import { compileScene, getItemMatrix } from "./compile.js";
|
|
6
7
|
|
|
7
8
|
const red = { normal: "rgb(255, 0, 0)" };
|
|
8
9
|
const scene = (groups: ReadonlyArray<Group>, rest: Partial<Scene> = {}): Scene => ({ size_deprecated: vec3(1, 1, 1), groups, ...rest });
|
|
9
|
-
const apply = (m: Matrix4, x: number, y: number, z: number): ReadonlyArray<number> =>
|
|
10
|
-
|
|
10
|
+
const apply = (m: Matrix4 | undefined, x: number, y: number, z: number): ReadonlyArray<number> => {
|
|
11
|
+
assert(m);
|
|
12
|
+
return new Vector3(x, y, z)
|
|
11
13
|
.applyMatrix4(m)
|
|
12
14
|
.toArray()
|
|
13
15
|
.map((v) => Math.round(v * 1000) / 1000 + 0);
|
|
16
|
+
};
|
|
17
|
+
const getItem = (compiled: CompiledScene, index: number): Item => {
|
|
18
|
+
const item = compiled.items.at(index);
|
|
19
|
+
assert(item);
|
|
20
|
+
return item;
|
|
21
|
+
};
|
|
22
|
+
// Destructured, as the build's and lint's compiler options disagree on reading an index signature by dot or bracket
|
|
23
|
+
const groupId = (g: Group): string => {
|
|
24
|
+
const { id = "" }: Record<string, string | undefined> = g.data ?? {};
|
|
25
|
+
return id;
|
|
26
|
+
};
|
|
14
27
|
|
|
15
28
|
describe("compileScene", () => {
|
|
16
29
|
it("flattens nested groups into items with world matrices", () => {
|
|
17
30
|
const inner = group([sphere(1, red)], vec3(0, 10, 0));
|
|
18
31
|
const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)], vec3(100, 0, 0), vec3Zero, [inner])]));
|
|
19
32
|
expect(compiled.items.map((i) => i.role)).toEqual(["solid", "solid"]);
|
|
20
|
-
expect(apply(compiled.items
|
|
21
|
-
expect(apply(compiled.items
|
|
33
|
+
expect(apply(compiled.items.at(0)?.matrix, 0, 0, 0)).toEqual([100, 0, 0]);
|
|
34
|
+
expect(apply(compiled.items.at(1)?.matrix, 0, 0, 0)).toEqual([100, 10, 0]);
|
|
22
35
|
});
|
|
23
36
|
|
|
24
37
|
it("applies group rotation before the group's position", () => {
|
|
25
38
|
const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)], vec3(5, 0, 0), vec3(0, 0, Math.PI / 2))]));
|
|
26
|
-
expect(apply(compiled.items
|
|
39
|
+
expect(apply(compiled.items.at(0)?.matrix, 1, 0, 0)).toEqual([5, 1, 0]);
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
it("rotates the scene by its deprecated rotation, then moves it by minus its deprecated center", () => {
|
|
30
43
|
const g = group([box(vec3(1, 1, 1), red)], vec3(10, 0, 0));
|
|
31
44
|
const centered = compileScene(scene([g], { center_deprecated: vec3(10, 0, 0) }));
|
|
32
|
-
expect(apply(centered.items
|
|
45
|
+
expect(apply(centered.items.at(0)?.matrix, 0, 0, 0)).toEqual([0, 0, 0]);
|
|
33
46
|
const rotated = compileScene(scene([g], { center_deprecated: vec3(-10, 0, 0), rotation_deprecated: vec3(0, Math.PI, 0) }));
|
|
34
|
-
expect(apply(rotated.items
|
|
47
|
+
expect(apply(rotated.items.at(0)?.matrix, 1, 0, 0)).toEqual([-1, 0, 0]);
|
|
35
48
|
});
|
|
36
49
|
|
|
37
50
|
it("gives every item of a top-level group that group's target, and the data of the group it's in", () => {
|
|
38
51
|
const inner = group([sphere(1, red)], vec3Zero, vec3Zero, [], { part: "motor" });
|
|
39
52
|
const fan = group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [inner], { id: "fan" });
|
|
40
|
-
const compiled = compileScene(scene([fan, group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { id: "coil" })]),
|
|
53
|
+
const compiled = compileScene(scene([fan, group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { id: "coil" })]), groupId);
|
|
41
54
|
expect(compiled.targets).toEqual([
|
|
42
55
|
{ kind: "group", id: "fan", root: 0, rootData: { id: "fan" } },
|
|
43
56
|
{ kind: "group", id: "coil", root: 1, rootData: { id: "coil" } },
|
|
@@ -70,9 +83,9 @@ describe("compileScene", () => {
|
|
|
70
83
|
);
|
|
71
84
|
const compiled = compileScene(scene([group([], vec3(10, 0, 0), vec3Zero, [fan])]));
|
|
72
85
|
expect(compiled.animations).toHaveLength(1);
|
|
73
|
-
expect(compiled.animations
|
|
74
|
-
expect(compiled.items
|
|
75
|
-
const world = (seconds: number): ReadonlyArray<number> => apply(getItemMatrix(compiled, compiled
|
|
86
|
+
expect(compiled.animations.at(0)?.speed).toEqual(vec3(0, 0, Math.PI));
|
|
87
|
+
expect(compiled.items.at(0)?.animation).toBe(0);
|
|
88
|
+
const world = (seconds: number): ReadonlyArray<number> => apply(getItemMatrix(compiled, getItem(compiled, 0), seconds, new Matrix4()), 1, 0, 0);
|
|
76
89
|
expect(world(0)).toEqual([11, 5, 0]);
|
|
77
90
|
expect(world(0.5)).toEqual([10, 6, 0]);
|
|
78
91
|
});
|
|
@@ -82,8 +95,8 @@ describe("compileScene", () => {
|
|
|
82
95
|
const inner = group([box(vec3(1, 1, 1), red)], vec3(1, 0, 0), vec3Zero, [], {}, spin);
|
|
83
96
|
const compiled = compileScene(scene([group([], vec3Zero, vec3Zero, [inner], {}, spin)]));
|
|
84
97
|
expect(compiled.animations.map((a) => a.parent)).toEqual([undefined, 0]);
|
|
85
|
-
expect(apply(getItemMatrix(compiled, compiled
|
|
86
|
-
expect(apply(getItemMatrix(compiled, compiled
|
|
98
|
+
expect(apply(getItemMatrix(compiled, getItem(compiled, 0), 0.5, new Matrix4()), 0, 0, 0)).toEqual([0, 1, 0]);
|
|
99
|
+
expect(apply(getItemMatrix(compiled, getItem(compiled, 0), 0.5, new Matrix4()), 1, 0, 0)).toEqual([-1, 1, 0]);
|
|
87
100
|
});
|
|
88
101
|
|
|
89
102
|
it("converts aligned dimensions to dimension items with a target per dimension", () => {
|
|
@@ -92,22 +105,22 @@ describe("compileScene", () => {
|
|
|
92
105
|
const items = compiled.items.filter((i) => i.style === "dimension");
|
|
93
106
|
expect(items.length).toBeGreaterThan(0);
|
|
94
107
|
expect(new Set(items.map((i) => i.role))).toEqual(new Set(["line", "text", "solid"]));
|
|
95
|
-
expect(compiled.targets[items
|
|
108
|
+
expect(compiled.targets[items.at(0)?.target ?? -1]).toEqual({ kind: "dimension", id: "fan" });
|
|
96
109
|
});
|
|
97
110
|
|
|
98
111
|
it("adds relation shapes, with an outline around their boxes", () => {
|
|
99
112
|
const relation = { id: "r1", pos: vec3Zero, groups: [group([box(vec3(10, 10, 10), red), sphere(1, red)], vec3(5, 0, 0))] };
|
|
100
113
|
const compiled = compileScene(scene([], { relations: [relation] }));
|
|
101
114
|
expect(compiled.items.map((i) => i.style)).toEqual(["relation", "outline", "relation"]);
|
|
102
|
-
expect(compiled.targets[compiled
|
|
103
|
-
const outline = compiled
|
|
115
|
+
expect(compiled.targets[getItem(compiled, 0).target]).toEqual({ kind: "relation", relation });
|
|
116
|
+
const outline = getItem(compiled, 1).mesh.geometry;
|
|
104
117
|
expect(outline.type === "Box" && outline.size).toEqual(vec3(10.125, 10.125, 10.125));
|
|
105
|
-
expect(apply(compiled.items
|
|
118
|
+
expect(apply(compiled.items.at(0)?.matrix, 0, 0, 0)).toEqual([5, 0, 0]);
|
|
106
119
|
});
|
|
107
120
|
|
|
108
121
|
it("keeps the world matrix of each top-level group for its tooltips", () => {
|
|
109
122
|
const compiled = compileScene(scene([group([], vec3(3, 0, 0))], { center_deprecated: vec3(1, 0, 0) }));
|
|
110
|
-
expect(apply(compiled.roots
|
|
123
|
+
expect(apply(compiled.roots.at(0), 0, 0, 0)).toEqual([2, 0, 0]);
|
|
111
124
|
expect(apply(compiled.matrix, 0, 0, 0)).toEqual([-1, 0, 0]);
|
|
112
125
|
});
|
|
113
126
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { Quality, TierGovernor } from "./quality.js";
|
|
3
|
-
import { recordFrame } from "./quality.js";
|
|
3
|
+
import { getPixelRatio, recordFrame } from "./quality.js";
|
|
4
4
|
|
|
5
|
-
const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "
|
|
5
|
+
const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "high" : quality, sum: 0, count: 0 });
|
|
6
6
|
const record = (g: TierGovernor, intervals: ReadonlyArray<number>): ReadonlyArray<boolean> => intervals.map((ms) => recordFrame(g, ms));
|
|
7
7
|
|
|
8
8
|
describe("recordFrame", () => {
|
|
@@ -19,12 +19,19 @@ describe("recordFrame", () => {
|
|
|
19
19
|
expect(g.tier).toBe("low");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("keeps auto at
|
|
22
|
+
it("keeps auto at high at 60 frames per second, ignoring the pauses between moves", () => {
|
|
23
23
|
const g = governor("auto");
|
|
24
24
|
record(
|
|
25
25
|
g,
|
|
26
26
|
Array.from({ length: 100 }, (_, i) => (i % 10 === 0 ? 2000 : 16.7))
|
|
27
27
|
);
|
|
28
|
-
expect(g.tier).toBe("
|
|
28
|
+
expect(g.tier).toBe("high");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("getPixelRatio", () => {
|
|
33
|
+
it("caps the screen's pixel ratio by the tier, and high is the screen's own, as the r3f renderer's", () => {
|
|
34
|
+
expect([getPixelRatio("low", 3), getPixelRatio("medium", 3), getPixelRatio("high", 3)]).toEqual([1, 1.5, 3]);
|
|
35
|
+
expect(getPixelRatio("high", 1)).toBe(1);
|
|
29
36
|
});
|
|
30
37
|
});
|
|
@@ -1,35 +1,20 @@
|
|
|
1
|
-
import { N8AOPass } from "n8ao";
|
|
2
|
-
import type { OrthographicCamera, PerspectiveCamera, Scene, WebGLRenderer } from "three";
|
|
3
|
-
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
|
|
4
|
-
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
|
|
5
|
-
|
|
6
1
|
/**
|
|
7
|
-
* Quality tiers
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* Quality tiers by resolution, the cheapest lever on a weak GPU: low renders a pixel per CSS pixel, medium up to 1.5, and high, the default, the
|
|
3
|
+
* screen's own pixel ratio as the r3f renderer. `auto` starts high and drops to low for good once moving frames are slow, as model viewers lower
|
|
4
|
+
* their resolution under load
|
|
10
5
|
*/
|
|
11
6
|
|
|
12
7
|
export type Tier = "low" | "medium" | "high";
|
|
13
8
|
export type Quality = Tier | "auto";
|
|
14
9
|
|
|
15
|
-
/** `auto` starts at
|
|
10
|
+
/** `auto` starts at high, and drops to low for good once moving frames average slower than 40 per second */
|
|
16
11
|
export type TierGovernor = { readonly quality: Quality; tier: Tier; sum: number; count: number };
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
readonly tier: Tier;
|
|
20
|
-
/** With ambient occlusion, faded in by its intensity over the first frames at rest */
|
|
21
|
-
readonly composer: EffectComposer | undefined;
|
|
22
|
-
readonly ao: { readonly configuration: { intensity: number } } | undefined;
|
|
23
|
-
readonly fade: { frame: number };
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: 2 };
|
|
13
|
+
const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: Infinity };
|
|
27
14
|
const windowSize = 30;
|
|
28
15
|
const slowFrameMs = 25;
|
|
29
16
|
/** Longer intervals are pauses between moves, not slow frames */
|
|
30
17
|
const pauseMs = 250;
|
|
31
|
-
const fadeFrames = 6;
|
|
32
|
-
const intensity = 2;
|
|
33
18
|
|
|
34
19
|
/** Records the time between two frames while the camera moves; true when that dropped the tier */
|
|
35
20
|
export function recordFrame(governor: TierGovernor, intervalMs: number): boolean {
|
|
@@ -46,50 +31,6 @@ export function recordFrame(governor: TierGovernor, intervalMs: number): boolean
|
|
|
46
31
|
return slow;
|
|
47
32
|
}
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
renderer.setPixelRatio(Math.min(window.devicePixelRatio, pixelRatioCaps[tier]));
|
|
52
|
-
if (tier === "low") {
|
|
53
|
-
return { tier, composer: undefined, ao: undefined, fade: { frame: 0 } };
|
|
54
|
-
}
|
|
55
|
-
const composer = new EffectComposer(renderer);
|
|
56
|
-
const ao = new N8AOPass(scene, camera);
|
|
57
|
-
Object.assign(ao.configuration, {
|
|
58
|
-
aoRadius: 40,
|
|
59
|
-
distanceFalloff: 0.2,
|
|
60
|
-
screenSpaceRadius: true,
|
|
61
|
-
halfRes: true,
|
|
62
|
-
denoiseSamples: 4,
|
|
63
|
-
gammaCorrection: false,
|
|
64
|
-
});
|
|
65
|
-
// Multisampled, as SMAA, which N8AO suggests, would lose the antialiased alpha at edges over the transparent background
|
|
66
|
-
ao.beautyRenderTarget.samples = 4;
|
|
67
|
-
composer.addPass(ao);
|
|
68
|
-
// Tone mapping and sRGB, which rendering to the screen otherwise does
|
|
69
|
-
composer.addPass(new OutputPass());
|
|
70
|
-
return { tier, composer, ao, fade: { frame: 0 } };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Renders a frame; true while the frame at rest needs more frames to fade its ambient occlusion in */
|
|
74
|
-
export function renderPath(
|
|
75
|
-
path: RenderPath,
|
|
76
|
-
renderer: WebGLRenderer,
|
|
77
|
-
scene: Scene,
|
|
78
|
-
camera: PerspectiveCamera | OrthographicCamera,
|
|
79
|
-
moving: boolean
|
|
80
|
-
): boolean {
|
|
81
|
-
if (!path.composer || !path.ao || (moving && path.tier === "medium")) {
|
|
82
|
-
path.fade.frame = 0;
|
|
83
|
-
renderer.render(scene, camera);
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
path.fade.frame = path.tier === "high" ? fadeFrames : Math.min(fadeFrames, path.fade.frame + 1);
|
|
87
|
-
path.ao.configuration.intensity = (intensity * path.fade.frame) / fadeFrames;
|
|
88
|
-
path.composer.render();
|
|
89
|
-
return path.fade.frame < fadeFrames;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function disposeRenderPath(path: RenderPath): void {
|
|
93
|
-
path.composer?.passes.forEach((p) => p.dispose());
|
|
94
|
-
path.composer?.dispose();
|
|
34
|
+
export function getPixelRatio(tier: Tier, devicePixelRatio: number): number {
|
|
35
|
+
return Math.min(devicePixelRatio, pixelRatioCaps[tier]);
|
|
95
36
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box3,
|
|
3
|
+
type Scene,
|
|
4
|
+
Mesh,
|
|
5
|
+
MeshBasicMaterial,
|
|
6
|
+
MeshDepthMaterial,
|
|
7
|
+
OrthographicCamera,
|
|
8
|
+
PlaneGeometry,
|
|
9
|
+
ShaderMaterial,
|
|
10
|
+
UniformsUtils,
|
|
11
|
+
Vector3,
|
|
12
|
+
type WebGLRenderer,
|
|
13
|
+
WebGLRenderTarget,
|
|
14
|
+
} from "three";
|
|
15
|
+
import { FullScreenQuad } from "three/addons/postprocessing/Pass.js";
|
|
16
|
+
import { HorizontalBlurShader } from "three/addons/shaders/HorizontalBlurShader.js";
|
|
17
|
+
import { VerticalBlurShader } from "three/addons/shaders/VerticalBlurShader.js";
|
|
18
|
+
import type { BuiltScene } from "../build/build.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A soft shadow on the ground the scene stands on, rendered once per build: the solids seen from below by their depth, darker the closer to the
|
|
22
|
+
* ground, blurred into a texture on a plane, so a frame costs one textured quad. three.js's webgl_shadow_contact example, rendered once as drei's
|
|
23
|
+
* ContactShadows with `frames={1}`
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type GroundShadow = { readonly mesh: Mesh<PlaneGeometry, MeshBasicMaterial>; readonly target: WebGLRenderTarget };
|
|
27
|
+
|
|
28
|
+
/** The layer the solids are drawn on for the shadow, so text, lines, images and dimensions don't cast one */
|
|
29
|
+
const shadowLayer = 1;
|
|
30
|
+
/** Of the texture's longer side */
|
|
31
|
+
const resolution = 512;
|
|
32
|
+
/** The blur's reach, of the solids' height */
|
|
33
|
+
const softness = 0.15;
|
|
34
|
+
const opacity = 0.8;
|
|
35
|
+
|
|
36
|
+
/** Undefined for a scene without solids; renders with `renderer`, and leaves its render target as it was */
|
|
37
|
+
export function createGroundShadow(renderer: WebGLRenderer, scene: Scene, built: BuiltScene): GroundShadow | undefined {
|
|
38
|
+
const bounds = new Box3();
|
|
39
|
+
built.parts.forEach((part) => {
|
|
40
|
+
if (part.kind === "solid" && part.object.parent !== built.dimensions && part.object.boundingBox) {
|
|
41
|
+
part.object.layers.enable(shadowLayer);
|
|
42
|
+
bounds.union(part.object.boundingBox);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (bounds.isEmpty()) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const size = bounds.getSize(new Vector3());
|
|
49
|
+
// The plane reaches past the solids by twice the blur's reach on every side
|
|
50
|
+
const reach = softness * size.y;
|
|
51
|
+
const [width, depth] = [size.x + reach * 4, size.z + reach * 4];
|
|
52
|
+
const center = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2);
|
|
53
|
+
const [texelsX, texelsZ] =
|
|
54
|
+
width > depth ? [resolution, Math.ceil((resolution * depth) / width)] : [Math.ceil((resolution * width) / depth), resolution];
|
|
55
|
+
const [target, blurred] = [new WebGLRenderTarget(texelsX, texelsZ), new WebGLRenderTarget(texelsX, texelsZ)];
|
|
56
|
+
|
|
57
|
+
// Looks up from the ground, seeing as far as the solids are high
|
|
58
|
+
const camera = new OrthographicCamera(-width / 2, width / 2, depth / 2, -depth / 2, 0, size.y);
|
|
59
|
+
camera.position.copy(center);
|
|
60
|
+
camera.rotation.x = Math.PI / 2;
|
|
61
|
+
camera.layers.set(shadowLayer);
|
|
62
|
+
camera.updateMatrixWorld();
|
|
63
|
+
// Depth tested, so at each point the surface closest to the ground darkens it, not the last one drawn
|
|
64
|
+
const darkness = new MeshDepthMaterial();
|
|
65
|
+
darkness.onBeforeCompile = (shader) => {
|
|
66
|
+
shader.fragmentShader = shader.fragmentShader.replace(
|
|
67
|
+
"gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );",
|
|
68
|
+
"gl_FragColor = vec4( vec3( 0.0 ), 1.0 - fragCoordZ );"
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
const previous = renderer.getRenderTarget();
|
|
72
|
+
Object.assign(scene, { overrideMaterial: darkness });
|
|
73
|
+
renderer.setRenderTarget(target);
|
|
74
|
+
renderer.render(scene, camera);
|
|
75
|
+
Object.assign(scene, { overrideMaterial: null });
|
|
76
|
+
|
|
77
|
+
const horizontal = new ShaderMaterial({ ...HorizontalBlurShader, uniforms: UniformsUtils.clone(HorizontalBlurShader.uniforms) });
|
|
78
|
+
const vertical = new ShaderMaterial({ ...VerticalBlurShader, uniforms: UniformsUtils.clone(VerticalBlurShader.uniforms) });
|
|
79
|
+
const quad = new FullScreenQuad(horizontal);
|
|
80
|
+
// The blur shaders reach 4 steps; three passes of shrinking steps blend into one smooth falloff
|
|
81
|
+
const step = (reach * texelsX) / width / 4;
|
|
82
|
+
[step, step / 2, step / 4].forEach((amount) => {
|
|
83
|
+
Object.assign(horizontal.uniforms, { tDiffuse: { value: target.texture }, h: { value: amount / texelsX } });
|
|
84
|
+
quad.material = horizontal;
|
|
85
|
+
renderer.setRenderTarget(blurred);
|
|
86
|
+
quad.render(renderer);
|
|
87
|
+
Object.assign(vertical.uniforms, { tDiffuse: { value: blurred.texture }, v: { value: amount / texelsZ } });
|
|
88
|
+
quad.material = vertical;
|
|
89
|
+
renderer.setRenderTarget(target);
|
|
90
|
+
quad.render(renderer);
|
|
91
|
+
});
|
|
92
|
+
renderer.setRenderTarget(previous);
|
|
93
|
+
[blurred, darkness, horizontal, vertical, quad].forEach((d) => d.dispose());
|
|
94
|
+
|
|
95
|
+
// The plane faces down as the camera looked up, so its texture lines up, and is mirrored to face up
|
|
96
|
+
const mesh = new Mesh(
|
|
97
|
+
new PlaneGeometry(width, depth).rotateX(Math.PI / 2),
|
|
98
|
+
new MeshBasicMaterial({ map: target.texture, transparent: true, depthWrite: false, opacity })
|
|
99
|
+
);
|
|
100
|
+
mesh.position.copy(center);
|
|
101
|
+
mesh.scale.y = -1;
|
|
102
|
+
scene.add(mesh);
|
|
103
|
+
return { mesh, target };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function disposeGroundShadow(shadow: GroundShadow): void {
|
|
107
|
+
shadow.mesh.removeFromParent();
|
|
108
|
+
shadow.mesh.geometry.dispose();
|
|
109
|
+
shadow.mesh.material.dispose();
|
|
110
|
+
shadow.target.dispose();
|
|
111
|
+
}
|
|
@@ -34,7 +34,7 @@ export type ViewerOptions<Content> = {
|
|
|
34
34
|
*/
|
|
35
35
|
readonly activeRelations?: ReadonlyArray<string>;
|
|
36
36
|
readonly showDimensions?: boolean;
|
|
37
|
-
/** View, buffer zones and padding are read when the scene is fitted: first, and for a new camera or canvas
|
|
37
|
+
/** View, buffer zones and padding are read when the scene is fitted: first, and for a new camera or canvas aspect */
|
|
38
38
|
readonly camera?: CameraOptions;
|
|
39
39
|
readonly view?: View;
|
|
40
40
|
readonly bufferZones?: BufferZones;
|
|
@@ -46,7 +46,9 @@ export type ViewerOptions<Content> = {
|
|
|
46
46
|
/** Lights faces by which way they turn and gives metal reflections: `studio` by default, `room` for three's RoomEnvironment, or null for none */
|
|
47
47
|
readonly environment?: Texture | "studio" | "room" | null;
|
|
48
48
|
readonly environmentIntensity?: number;
|
|
49
|
-
/**
|
|
49
|
+
/** A soft shadow on the ground under the scene, rendered once per build; on by default */
|
|
50
|
+
readonly groundShadow?: boolean;
|
|
51
|
+
/** The resolution: `low` 1 and `medium` up to 1.5 pixels per CSS pixel, `high`, the default, the screen's; `auto` high until moving gets slow */
|
|
50
52
|
readonly quality?: Quality;
|
|
51
53
|
readonly useAlphaTest?: boolean;
|
|
52
54
|
readonly materialStateImages?: Record<string, string>;
|
|
@@ -61,10 +63,12 @@ export type ViewerOptions<Content> = {
|
|
|
61
63
|
readonly onHoverGroup?: (id: string | undefined, rootData: Data, data: Data, event: SceneEvent) => void;
|
|
62
64
|
readonly onContextMenuGroup?: (id: string, rootData: Data, data: Data, left: number, top: number, event: SceneEvent) => void;
|
|
63
65
|
readonly onClickRelation?: (relation: Relation, event: SceneEvent) => void;
|
|
66
|
+
/** A click, not a drag, on nothing clickable, e.g. the background; r3f's Canvas event of the same name */
|
|
67
|
+
readonly onPointerMissed?: (event: MouseEvent) => void;
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
/** What an update has to redo, in the order it's redone */
|
|
67
|
-
export type Change = "compile" | "build" | "camera" | "controls" | "gizmo" | "lights" | "quality" | "paint" | "dimensions" | "overlays";
|
|
71
|
+
export type Change = "compile" | "build" | "shadow" | "camera" | "controls" | "gizmo" | "lights" | "quality" | "paint" | "dimensions" | "overlays";
|
|
68
72
|
|
|
69
73
|
/** What passing each option redoes; callbacks and what's read later need nothing */
|
|
70
74
|
export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: ReadonlyArray<Change> } = {
|
|
@@ -86,6 +90,7 @@ export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: Readonly
|
|
|
86
90
|
lights: ["lights"],
|
|
87
91
|
environment: ["lights"],
|
|
88
92
|
environmentIntensity: ["lights"],
|
|
93
|
+
groundShadow: ["shadow"],
|
|
89
94
|
quality: ["quality"],
|
|
90
95
|
popovers: ["overlays"],
|
|
91
96
|
getTooltips: ["overlays"],
|
|
@@ -95,4 +100,5 @@ export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: Readonly
|
|
|
95
100
|
onHoverGroup: [],
|
|
96
101
|
onContextMenuGroup: [],
|
|
97
102
|
onClickRelation: [],
|
|
103
|
+
onPointerMissed: [],
|
|
98
104
|
};
|
|
@@ -32,8 +32,10 @@ import { configureControls, createControls, setPose } from "../camera/controls.j
|
|
|
32
32
|
import { createGizmo } from "../camera/gizmo.js";
|
|
33
33
|
import type { CompiledScene } from "../compile/compile.js";
|
|
34
34
|
import { compileScene } from "../compile/compile.js";
|
|
35
|
-
import type {
|
|
36
|
-
import {
|
|
35
|
+
import type { TierGovernor } from "../quality/quality.js";
|
|
36
|
+
import { getPixelRatio, recordFrame } from "../quality/quality.js";
|
|
37
|
+
import type { GroundShadow } from "../shadow/shadow.js";
|
|
38
|
+
import { createGroundShadow, disposeGroundShadow } from "../shadow/shadow.js";
|
|
37
39
|
import type { GlyphAtlas } from "../text/text.js";
|
|
38
40
|
import { createGlyphAtlas } from "../text/text.js";
|
|
39
41
|
import type { Change, ViewerOptions } from "./options.js";
|
|
@@ -77,7 +79,7 @@ export type Viewer<Content> = {
|
|
|
77
79
|
compiled?: CompiledScene;
|
|
78
80
|
built?: BuiltScene;
|
|
79
81
|
gizmo?: ViewportGizmo;
|
|
80
|
-
|
|
82
|
+
shadow?: GroundShadow;
|
|
81
83
|
governor?: TierGovernor;
|
|
82
84
|
readonly environments: Partial<Record<"studio" | "room", Texture>>;
|
|
83
85
|
/** The scene is built and its shaders compiled */
|
|
@@ -164,12 +166,12 @@ export function disposeViewer<Content>(v: Viewer<Content>): void {
|
|
|
164
166
|
v.resizeObserver.disconnect();
|
|
165
167
|
v.controls.orbit.dispose();
|
|
166
168
|
v.gizmo?.dispose();
|
|
167
|
-
if (v.path) {
|
|
168
|
-
disposeRenderPath(v.path);
|
|
169
|
-
}
|
|
170
169
|
if (v.built) {
|
|
171
170
|
disposeScene(v.built);
|
|
172
171
|
}
|
|
172
|
+
if (v.shadow) {
|
|
173
|
+
disposeGroundShadow(v.shadow);
|
|
174
|
+
}
|
|
173
175
|
Object.values(v.environments).forEach((e) => e.dispose());
|
|
174
176
|
v.geometries.forEach((g) => g.dispose());
|
|
175
177
|
v.atlas.texture.dispose();
|
|
@@ -191,16 +193,22 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
|
|
|
191
193
|
v.structure = { activeComponents: options.activeComponents, relationsActive: options.activeRelations !== undefined };
|
|
192
194
|
rebuild(v);
|
|
193
195
|
}
|
|
196
|
+
if (changes.has("shadow") || changes.has("build")) {
|
|
197
|
+
if (v.shadow) {
|
|
198
|
+
disposeGroundShadow(v.shadow);
|
|
199
|
+
}
|
|
200
|
+
v.shadow = v.built && options.groundShadow !== false ? createGroundShadow(v.renderer, v.scene, v.built) : undefined;
|
|
201
|
+
}
|
|
194
202
|
if (changes.has("camera")) {
|
|
195
203
|
const { near = 0.1, far = 2000, ...rest } = options.camera ?? { type: "Perspective" };
|
|
196
204
|
v.camera = v.cameras[rest.type];
|
|
197
|
-
Object.assign(v.camera, { near, far, ...(
|
|
205
|
+
Object.assign(v.camera, { near, far, ...(rest.type === "Perspective" ? { fov: rest.fov ?? 45 } : {}) });
|
|
198
206
|
v.controls.orbit.object = v.camera;
|
|
199
207
|
}
|
|
200
208
|
if (changes.has("controls")) {
|
|
201
209
|
configureControls(v.controls, options.controls ?? {});
|
|
202
210
|
}
|
|
203
|
-
// As the r3f renderer, the scene is fitted at first, and for a new camera or canvas
|
|
211
|
+
// As the r3f renderer, the scene is fitted at first, and for a new camera or canvas aspect
|
|
204
212
|
if ((changes.has("build") && !v.fitted) || changes.has("camera")) {
|
|
205
213
|
fitPose(v);
|
|
206
214
|
}
|
|
@@ -211,11 +219,9 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
|
|
|
211
219
|
updateLights(v);
|
|
212
220
|
}
|
|
213
221
|
if (changes.has("quality")) {
|
|
214
|
-
const quality = options.quality ?? "
|
|
215
|
-
v.governor = { quality, tier: quality === "auto" ? "
|
|
216
|
-
|
|
217
|
-
if (changes.has("quality") || changes.has("camera")) {
|
|
218
|
-
updateRenderPath(v);
|
|
222
|
+
const quality = options.quality ?? "high";
|
|
223
|
+
v.governor = { quality, tier: quality === "auto" ? "high" : quality, sum: 0, count: 0 };
|
|
224
|
+
v.renderer.setPixelRatio(getPixelRatio(v.governor.tier, window.devicePixelRatio));
|
|
219
225
|
}
|
|
220
226
|
if ((changes.has("paint") || changes.has("build")) && v.built) {
|
|
221
227
|
const start = performance.now();
|
|
@@ -284,7 +290,7 @@ function renderFrame<Content>(v: Viewer<Content>, now: number): void {
|
|
|
284
290
|
renderer.info.reset();
|
|
285
291
|
const moving = v.controls.orbit.update(Math.min(interval / 1000, 0.1)) || !!gizmo?.animating;
|
|
286
292
|
if (moving && loop.wasMoving && v.governor && recordFrame(v.governor, interval)) {
|
|
287
|
-
|
|
293
|
+
renderer.setPixelRatio(getPixelRatio(v.governor.tier, window.devicePixelRatio));
|
|
288
294
|
}
|
|
289
295
|
loop.wasMoving = moving;
|
|
290
296
|
const animated = !!built && built.animatedItems.length > 0;
|
|
@@ -296,33 +302,38 @@ function renderFrame<Content>(v: Viewer<Content>, now: number): void {
|
|
|
296
302
|
if (built) {
|
|
297
303
|
cullScene(built, camera.position);
|
|
298
304
|
}
|
|
299
|
-
|
|
305
|
+
if (v.loaded) {
|
|
306
|
+
renderer.render(v.scene, camera);
|
|
307
|
+
}
|
|
300
308
|
gizmo?.render();
|
|
301
309
|
v.overlays.renderer.render(v.overlays.scene, camera);
|
|
302
310
|
Object.assign(v.stats, { frames: v.stats.frames + 1, frameMs: performance.now() - start, drawCalls: renderer.info.render.calls });
|
|
303
|
-
if (moving || animated
|
|
311
|
+
if (moving || animated) {
|
|
304
312
|
invalidate(v);
|
|
305
313
|
} else {
|
|
306
314
|
loop.last = 0;
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
318
|
+
/** As the r3f renderer, a resize refits only when the canvas' aspect changes */
|
|
310
319
|
function onResize<Content>(v: Viewer<Content>, rect: DOMRectReadOnly): void {
|
|
320
|
+
const aspectChanged = rect.width / rect.height !== v.size.width / v.size.height;
|
|
311
321
|
Object.assign(v.size, { width: rect.width, height: rect.height });
|
|
312
322
|
v.renderer.setSize(rect.width, rect.height, false);
|
|
313
|
-
v.path?.composer?.setSize(rect.width, rect.height);
|
|
314
323
|
v.overlays.renderer.setSize(rect.width, rect.height);
|
|
315
324
|
v.gizmo?.update();
|
|
316
|
-
|
|
325
|
+
if (aspectChanged) {
|
|
326
|
+
fitPose(v);
|
|
327
|
+
}
|
|
317
328
|
invalidate(v);
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
/** Fits the projection to the scene, and moves the camera to look
|
|
331
|
+
/** Fits the projection to the scene, and moves the camera to look from the view; as the r3f renderer, the target stays where it was panned to */
|
|
321
332
|
function fitPose<Content>(v: Viewer<Content>): void {
|
|
322
333
|
const placement = fitScene(v);
|
|
323
334
|
if (placement) {
|
|
324
335
|
const { x, y, z } = placement.direction;
|
|
325
|
-
setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance),
|
|
336
|
+
setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance), v.controls.orbit.target);
|
|
326
337
|
v.fitted = true;
|
|
327
338
|
}
|
|
328
339
|
}
|
|
@@ -369,13 +380,16 @@ function hoverAtPointer<Content>(v: Viewer<Content>): void {
|
|
|
369
380
|
|
|
370
381
|
function onClick<Content>(v: Viewer<Content>, event: MouseEvent): void {
|
|
371
382
|
const { down } = v.pointer;
|
|
372
|
-
const
|
|
383
|
+
const clicked = !!down && Math.hypot(event.clientX - down.x, event.clientY - down.y) <= clickTolerance;
|
|
384
|
+
const hit = clicked ? pick(v, event) : undefined;
|
|
373
385
|
const target = hit && v.compiled!.targets[hit.target];
|
|
374
386
|
const args = hit && getGroupArgs(v, hit);
|
|
375
387
|
if (hit && target?.kind === "relation") {
|
|
376
388
|
v.options.onClickRelation?.(target.relation, { nativeEvent: event, point: hit.point });
|
|
377
389
|
} else if (hit && args) {
|
|
378
390
|
v.options.onClickGroup?.(...args, { nativeEvent: event, point: hit.point });
|
|
391
|
+
} else if (clicked) {
|
|
392
|
+
v.options.onPointerMissed?.(event);
|
|
379
393
|
}
|
|
380
394
|
}
|
|
381
395
|
|
|
@@ -529,12 +543,5 @@ function updateOverlays<Content>(v: Viewer<Content>): void {
|
|
|
529
543
|
setOverlays(v.overlays, overlays, options.onOverlays);
|
|
530
544
|
}
|
|
531
545
|
|
|
532
|
-
function updateRenderPath<Content>(v: Viewer<Content>): void {
|
|
533
|
-
if (v.path) {
|
|
534
|
-
disposeRenderPath(v.path);
|
|
535
|
-
}
|
|
536
|
-
v.path = createRenderPath(v.renderer, v.scene, v.camera, v.governor?.tier ?? "low");
|
|
537
|
-
}
|
|
538
|
-
|
|
539
546
|
const raycaster = new Raycaster();
|
|
540
547
|
const ndc = new Vector2();
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
declare module "n8ao" {
|
|
2
|
-
import type { Camera, Scene, WebGLRenderTarget } from "three";
|
|
3
|
-
import type { Pass } from "three/addons/postprocessing/Pass.js";
|
|
4
|
-
|
|
5
|
-
type AOPass = Pass & {
|
|
6
|
-
readonly configuration: Record<string, unknown> & { intensity: number };
|
|
7
|
-
readonly beautyRenderTarget: WebGLRenderTarget;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export const N8AOPass: new (scene: Scene, camera: Camera, width?: number, height?: number) => AOPass;
|
|
11
|
-
}
|