abstract-3d 4.0.0-beta.2 → 4.0.0-beta.7
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/abstract-3d.d.ts +22 -1
- package/lib/abstract-3d.d.ts.map +1 -1
- package/lib/abstract-3d.js +4 -2
- package/lib/abstract-3d.js.map +1 -1
- package/lib/renderers/react/react.js +3 -3
- package/lib/renderers/react/react.js.map +1 -1
- package/lib/renderers/three/appearance/appearance.d.ts +5 -0
- package/lib/renderers/three/appearance/appearance.d.ts.map +1 -1
- package/lib/renderers/three/appearance/appearance.js +15 -5
- package/lib/renderers/three/appearance/appearance.js.map +1 -1
- package/lib/renderers/three/build/build.js +4 -1
- package/lib/renderers/three/build/build.js.map +1 -1
- package/lib/renderers/three/build/materials.d.ts.map +1 -1
- package/lib/renderers/three/build/materials.js +74 -2
- package/lib/renderers/three/build/materials.js.map +1 -1
- package/lib/renderers/three/camera/gizmo.d.ts.map +1 -1
- package/lib/renderers/three/camera/gizmo.js +2 -1
- package/lib/renderers/three/camera/gizmo.js.map +1 -1
- package/lib/renderers/three/environment/studio.d.ts +8 -0
- package/lib/renderers/three/environment/studio.d.ts.map +1 -0
- package/lib/renderers/three/environment/studio.js +14 -0
- package/lib/renderers/three/environment/studio.js.map +1 -0
- package/lib/renderers/three/geometry/geometry.d.ts.map +1 -1
- package/lib/renderers/three/geometry/geometry.js +5 -2
- package/lib/renderers/three/geometry/geometry.js.map +1 -1
- package/lib/renderers/three/quality/quality.d.ts +3 -2
- package/lib/renderers/three/quality/quality.d.ts.map +1 -1
- package/lib/renderers/three/quality/quality.js +4 -3
- package/lib/renderers/three/quality/quality.js.map +1 -1
- package/lib/renderers/three/shadow/key-light.d.ts +6 -0
- package/lib/renderers/three/shadow/key-light.d.ts.map +1 -0
- package/lib/renderers/three/shadow/key-light.js +37 -0
- package/lib/renderers/three/shadow/key-light.js.map +1 -0
- package/lib/renderers/three/shadow/shadow.d.ts +7 -1
- package/lib/renderers/three/shadow/shadow.d.ts.map +1 -1
- package/lib/renderers/three/shadow/shadow.js +63 -24
- package/lib/renderers/three/shadow/shadow.js.map +1 -1
- package/lib/renderers/three/viewer/options.d.ts +7 -6
- package/lib/renderers/three/viewer/options.d.ts.map +1 -1
- package/lib/renderers/three/viewer/options.js +1 -1
- package/lib/renderers/three/viewer/options.js.map +1 -1
- package/lib/renderers/three/viewer/viewer.d.ts +7 -1
- package/lib/renderers/three/viewer/viewer.d.ts.map +1 -1
- package/lib/renderers/three/viewer/viewer.js +69 -65
- package/lib/renderers/three/viewer/viewer.js.map +1 -1
- package/package.json +1 -1
- package/src/abstract-3d.ts +32 -2
- package/src/renderers/ifc/ifc.test.ts +7 -5
- package/src/renderers/react/react.tsx +3 -3
- package/src/renderers/three/appearance/appearance.test.ts +36 -4
- package/src/renderers/three/appearance/appearance.ts +21 -6
- package/src/renderers/three/build/build.test.ts +18 -10
- package/src/renderers/three/build/build.ts +4 -1
- package/src/renderers/three/build/materials.ts +93 -2
- package/src/renderers/three/camera/gizmo.ts +2 -1
- package/src/renderers/three/compile/compile.test.ts +32 -19
- package/src/renderers/three/environment/studio.ts +17 -0
- package/src/renderers/three/geometry/geometry.test.ts +15 -1
- package/src/renderers/three/geometry/geometry.ts +5 -2
- package/src/renderers/three/quality/quality.test.ts +2 -2
- package/src/renderers/three/quality/quality.ts +4 -3
- package/src/renderers/three/shadow/key-light.ts +41 -0
- package/src/renderers/three/shadow/shadow.ts +79 -25
- package/src/renderers/three/viewer/options.ts +8 -7
- package/src/renderers/three/viewer/viewer.ts +75 -74
|
@@ -17,6 +17,8 @@ const bounds = (mesh: Mesh): ReadonlyArray<number> => {
|
|
|
17
17
|
return [...geometry.boundingBox!.min.toArray(), ...geometry.boundingBox!.max.toArray()].map((v) => Math.round(v * 1000) / 1000 + 0);
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const bevel = (mesh: Mesh): ReadonlyArray<number> => [...new Set(createSolidGeometry(mesh.geometry).getAttribute("bevel").array)];
|
|
21
|
+
|
|
20
22
|
describe("getSolidShape", () => {
|
|
21
23
|
it("shares unit geometries between primitives of any size, scaled by the local matrix", () => {
|
|
22
24
|
expect(key(box(vec3(1, 2, 3), m))).toBe(key(box(vec3(4, 5, 6), m)));
|
|
@@ -74,9 +76,21 @@ describe("createSolidGeometry", () => {
|
|
|
74
76
|
meshes.forEach((mesh) => {
|
|
75
77
|
const geometry = createSolidGeometry(mesh.geometry);
|
|
76
78
|
expect(geometry.index!.count).toBeGreaterThan(0);
|
|
77
|
-
expect(Object.keys(geometry.attributes).sort()).toEqual(["normal", "position", "uv"]);
|
|
79
|
+
expect(Object.keys(geometry.attributes).sort()).toEqual(["bevel", "normal", "position", "uv"]);
|
|
78
80
|
});
|
|
79
81
|
});
|
|
82
|
+
|
|
83
|
+
it("gives boxes with a radius it as the radius their edges are shaded rounded by, with a geometry per radius", () => {
|
|
84
|
+
expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).toEqual([4]);
|
|
85
|
+
expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).toBe(key(box(vec3(9, 9, 9), m, vec3Zero, vec3Zero, [], 4)));
|
|
86
|
+
expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).not.toBe(key(box(vec3(1, 1, 1), m)));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps boxes without a radius, with holes, and other shapes sharp", () => {
|
|
90
|
+
expect(bevel(box(vec3(1, 1, 1), m))).toEqual([0]);
|
|
91
|
+
expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0.1)], 4))).toEqual([0]);
|
|
92
|
+
expect(bevel(sphere(1, m))).toEqual([0]);
|
|
93
|
+
});
|
|
80
94
|
});
|
|
81
95
|
|
|
82
96
|
describe("keepGeometries", () => {
|
|
@@ -42,7 +42,7 @@ export function getSolidShape(g: Mesh["geometry"]): SolidShape | undefined {
|
|
|
42
42
|
const size = g.type === "Box" ? g.size : vec3(g.size.x, g.size.y, 1);
|
|
43
43
|
return g.holes?.some((h) => !holeIsZero(h))
|
|
44
44
|
? { key: JSON.stringify([g.type, g.size, g.holes]), local: transform(g.pos, g.rot) }
|
|
45
|
-
: { key: g.type, local: transform(g.pos, g.rot, undefined, size) };
|
|
45
|
+
: { key: g.type === "Box" && g.radius ? `Box ${g.radius}` : g.type, local: transform(g.pos, g.rot, undefined, size) };
|
|
46
46
|
}
|
|
47
47
|
case "Image":
|
|
48
48
|
return { key: "Plane", local: transform(g.pos, g.rot, undefined, vec3(g.size.x, g.size.y, 1)) };
|
|
@@ -86,8 +86,11 @@ export function createSolidGeometry(g: Mesh["geometry"]): BufferGeometry {
|
|
|
86
86
|
if (!geometry.hasAttribute("uv")) {
|
|
87
87
|
geometry.setAttribute("uv", new Float32BufferAttribute(new Float32Array(count * 2), 2));
|
|
88
88
|
}
|
|
89
|
+
// The radius a unit box's edges are shaded as rounded by, 0 for sharp; every geometry has it, as a batch's geometries share their attributes
|
|
90
|
+
const radius = geometry instanceof BoxGeometry && g.type === "Box" ? (g.radius ?? 0) : 0;
|
|
91
|
+
geometry.setAttribute("bevel", new Float32BufferAttribute(new Float32Array(count).fill(radius), 1));
|
|
89
92
|
Object.keys(geometry.attributes).forEach((name) => {
|
|
90
|
-
if (name !== "position" && name !== "normal" && name !== "uv") {
|
|
93
|
+
if (name !== "position" && name !== "normal" && name !== "uv" && name !== "bevel") {
|
|
91
94
|
geometry.deleteAttribute(name);
|
|
92
95
|
}
|
|
93
96
|
});
|
|
@@ -30,8 +30,8 @@ describe("recordFrame", () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe("getPixelRatio", () => {
|
|
33
|
-
it("caps the screen's pixel ratio by the tier", () => {
|
|
34
|
-
expect([getPixelRatio("low", 3), getPixelRatio("medium", 3), getPixelRatio("high", 3)]).toEqual([1, 1.5,
|
|
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
35
|
expect(getPixelRatio("high", 1)).toBe(1);
|
|
36
36
|
});
|
|
37
37
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
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
|
|
3
|
-
*
|
|
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
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
export type Tier = "low" | "medium" | "high";
|
|
@@ -9,7 +10,7 @@ export type Quality = Tier | "auto";
|
|
|
9
10
|
/** `auto` starts at high, and drops to low for good once moving frames average slower than 40 per second */
|
|
10
11
|
export type TierGovernor = { readonly quality: Quality; tier: Tier; sum: number; count: number };
|
|
11
12
|
|
|
12
|
-
const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high:
|
|
13
|
+
const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: Infinity };
|
|
13
14
|
const windowSize = 30;
|
|
14
15
|
const slowFrameMs = 25;
|
|
15
16
|
/** Longer intervals are pauses between moves, not slow frames */
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DirectionalLight, Group, Sphere, Vector3 } from "three";
|
|
2
|
+
import type { BuiltScene } from "../build/build.js";
|
|
3
|
+
import { getShadowCasters } from "./shadow.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A key light from the upper left front, on top of the studio's soft light, that casts the solids' shadows on themselves, e.g. under a roof or behind
|
|
7
|
+
* a profile. Its shadow map is rendered once per build, as the scene doesn't move, so a frame only samples it; the casters are the ground shadow's
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const direction = new Vector3(-1, 2, 1.2).normalize();
|
|
11
|
+
const intensity = 3.5;
|
|
12
|
+
const mapSize = 2048;
|
|
13
|
+
|
|
14
|
+
/** The light and its target, to add to the scene, with or without shadows; undefined for a scene without solids */
|
|
15
|
+
export function createKeyLight(built: BuiltScene, shadows: boolean): Group | undefined {
|
|
16
|
+
const { bounds } = getShadowCasters(built);
|
|
17
|
+
if (bounds.isEmpty()) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const { center, radius } = bounds.getBoundingSphere(new Sphere());
|
|
21
|
+
const light = new DirectionalLight(0xffffff, intensity);
|
|
22
|
+
light.position.copy(center).addScaledVector(direction, radius * 2);
|
|
23
|
+
light.target.position.copy(center);
|
|
24
|
+
light.castShadow = shadows;
|
|
25
|
+
// The shadow camera just encloses the scene, so the map's texels are as small as they can be
|
|
26
|
+
Object.assign(light.shadow.camera, { left: -radius, right: radius, top: radius, bottom: -radius, near: radius, far: radius * 3 });
|
|
27
|
+
light.shadow.camera.updateProjectionMatrix();
|
|
28
|
+
light.shadow.mapSize.set(mapSize, mapSize);
|
|
29
|
+
// Offsets along the normal by three texels, against shadow acne on the lit faces
|
|
30
|
+
Object.assign(light.shadow, { bias: -0.0005, normalBias: (radius * 6) / mapSize, radius: 3 });
|
|
31
|
+
return new Group().add(light, light.target);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function disposeKeyLight(key: Group): void {
|
|
35
|
+
key.removeFromParent();
|
|
36
|
+
key.children.forEach((child) => {
|
|
37
|
+
if (child instanceof DirectionalLight) {
|
|
38
|
+
child.shadow.dispose();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type BatchedMesh,
|
|
2
3
|
Box3,
|
|
4
|
+
CanvasTexture,
|
|
5
|
+
HalfFloatType,
|
|
3
6
|
type Scene,
|
|
4
|
-
Matrix4,
|
|
5
7
|
Mesh,
|
|
6
8
|
MeshBasicMaterial,
|
|
7
9
|
MeshDepthMaterial,
|
|
@@ -24,53 +26,67 @@ import type { BuiltScene } from "../build/build.js";
|
|
|
24
26
|
* ContactShadows with `frames={1}`
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
|
-
export type GroundShadow = {
|
|
29
|
+
export type GroundShadow = {
|
|
30
|
+
readonly mesh: Mesh<PlaneGeometry, MeshBasicMaterial>;
|
|
31
|
+
readonly floor: Mesh<PlaneGeometry, MeshBasicMaterial>;
|
|
32
|
+
readonly target: WebGLRenderTarget;
|
|
33
|
+
};
|
|
28
34
|
|
|
29
35
|
/** The layer the solids are drawn on for the shadow, so text, lines, images and dimensions don't cast one */
|
|
30
36
|
const shadowLayer = 1;
|
|
31
37
|
/** Of the texture's longer side */
|
|
32
38
|
const resolution = 512;
|
|
33
|
-
/** How far behind (-z) a part's shadow falls per unit of its height, as from a light in front and above */
|
|
34
|
-
const lean = 0.5;
|
|
35
39
|
/** The blur's reach, of the solids' height */
|
|
36
|
-
const softness = 0.
|
|
37
|
-
const opacity =
|
|
40
|
+
const softness = 0.22;
|
|
41
|
+
const opacity = 1;
|
|
42
|
+
/** A pool of light on the floor the shadow lies in, so it shows on a dark background, which a shadow alone can't darken; on white it's invisible */
|
|
43
|
+
const floorLight = 0.06;
|
|
44
|
+
/** The pool's reach beyond the solids, of their height */
|
|
45
|
+
const floorReach = 1;
|
|
38
46
|
|
|
39
|
-
/**
|
|
40
|
-
export function
|
|
47
|
+
/** The batches of solids that cast shadows, with their bounds: opaque ones whose material doesn't turn it off, not text, lines, images or dimensions */
|
|
48
|
+
export function getShadowCasters(built: BuiltScene): { readonly casters: ReadonlyArray<BatchedMesh>; readonly bounds: Box3 } {
|
|
49
|
+
const casters: Array<BatchedMesh> = [];
|
|
41
50
|
const bounds = new Box3();
|
|
42
51
|
built.parts.forEach((part) => {
|
|
43
|
-
if (part.kind === "solid" && part.object.parent !== built.dimensions && part.object.boundingBox) {
|
|
44
|
-
part.object
|
|
52
|
+
if (part.kind === "solid" && part.object.castShadow && part.object.parent !== built.dimensions && part.object.boundingBox) {
|
|
53
|
+
casters.push(part.object);
|
|
45
54
|
bounds.union(part.object.boundingBox);
|
|
46
55
|
}
|
|
47
56
|
});
|
|
57
|
+
return { casters, bounds };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Undefined for a scene without solids; renders with `renderer`, and leaves its render target as it was */
|
|
61
|
+
export function createGroundShadow(renderer: WebGLRenderer, scene: Scene, built: BuiltScene): GroundShadow | undefined {
|
|
62
|
+
const { casters, bounds } = getShadowCasters(built);
|
|
48
63
|
if (bounds.isEmpty()) {
|
|
49
64
|
return undefined;
|
|
50
65
|
}
|
|
66
|
+
casters.forEach((caster) => caster.layers.enable(shadowLayer));
|
|
51
67
|
const size = bounds.getSize(new Vector3());
|
|
52
|
-
// The plane reaches past the solids by twice the blur's reach
|
|
53
|
-
const
|
|
54
|
-
const [width, depth] = [size.x + reach * 4, size.z + reach * 4
|
|
55
|
-
const center = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z
|
|
68
|
+
// The plane reaches past the solids by twice the blur's reach on every side
|
|
69
|
+
const reach = softness * size.y;
|
|
70
|
+
const [width, depth] = [size.x + reach * 4, size.z + reach * 4];
|
|
71
|
+
const center = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2);
|
|
56
72
|
const [texelsX, texelsZ] =
|
|
57
73
|
width > depth ? [resolution, Math.ceil((resolution * depth) / width)] : [Math.ceil((resolution * width) / depth), resolution];
|
|
58
|
-
|
|
74
|
+
// Half float, so the blur's soft falloff isn't rounded into steps
|
|
75
|
+
const halfFloat = { type: HalfFloatType };
|
|
76
|
+
const [target, blurred] = [new WebGLRenderTarget(texelsX, texelsZ, halfFloat), new WebGLRenderTarget(texelsX, texelsZ, halfFloat)];
|
|
59
77
|
|
|
60
|
-
// Looks up from the ground, seeing as far as the solids are high
|
|
61
|
-
// a height h is -h in its view space
|
|
78
|
+
// Looks up from the ground, seeing as far as the solids are high
|
|
62
79
|
const camera = new OrthographicCamera(-width / 2, width / 2, depth / 2, -depth / 2, 0, size.y);
|
|
63
80
|
camera.position.copy(center);
|
|
64
81
|
camera.rotation.x = Math.PI / 2;
|
|
65
82
|
camera.layers.set(shadowLayer);
|
|
66
83
|
camera.updateMatrixWorld();
|
|
67
|
-
camera.projectionMatrix.multiply(new Matrix4().set(1, 0, 0, 0, 0, 1, lean, 0, 0, 0, 1, 0, 0, 0, 0, 1));
|
|
68
84
|
// Depth tested, so at each point the surface closest to the ground darkens it, not the last one drawn
|
|
69
85
|
const darkness = new MeshDepthMaterial();
|
|
70
86
|
darkness.onBeforeCompile = (shader) => {
|
|
71
87
|
shader.fragmentShader = shader.fragmentShader.replace(
|
|
72
88
|
"gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );",
|
|
73
|
-
"gl_FragColor = vec4( vec3( 0.0 ), 1.0 - fragCoordZ );"
|
|
89
|
+
"gl_FragColor = vec4( vec3( 0.0 ), sqrt( 1.0 - fragCoordZ ) );"
|
|
74
90
|
);
|
|
75
91
|
};
|
|
76
92
|
const previous = renderer.getRenderTarget();
|
|
@@ -100,17 +116,55 @@ export function createGroundShadow(renderer: WebGLRenderer, scene: Scene, built:
|
|
|
100
116
|
// The plane faces down as the camera looked up, so its texture lines up, and is mirrored to face up
|
|
101
117
|
const mesh = new Mesh(
|
|
102
118
|
new PlaneGeometry(width, depth).rotateX(Math.PI / 2),
|
|
103
|
-
new MeshBasicMaterial({ map: target.texture, transparent: true, depthWrite: false, opacity })
|
|
119
|
+
ditherAlpha(new MeshBasicMaterial({ map: target.texture, transparent: true, depthWrite: false, opacity }), 2)
|
|
104
120
|
);
|
|
105
121
|
mesh.position.copy(center);
|
|
106
122
|
mesh.scale.y = -1;
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
// The floor's pool of light first, then the shadow in it, both before other transparent objects
|
|
124
|
+
const floor = new Mesh(
|
|
125
|
+
new PlaneGeometry(size.x + size.y * floorReach * 2, size.z + size.y * floorReach * 2).rotateX(-Math.PI / 2),
|
|
126
|
+
ditherAlpha(new MeshBasicMaterial({ alphaMap: createPool(), transparent: true, depthWrite: false, opacity: floorLight }), 1)
|
|
127
|
+
);
|
|
128
|
+
floor.position.copy(center);
|
|
129
|
+
Object.assign(floor, { renderOrder: -2 });
|
|
130
|
+
Object.assign(mesh, { renderOrder: -1 });
|
|
131
|
+
scene.add(floor, mesh);
|
|
132
|
+
return { mesh, floor, target };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A radial falloff from the center to the edges, as a spotlight's pool on the floor */
|
|
136
|
+
function createPool(): CanvasTexture {
|
|
137
|
+
const canvas = Object.assign(document.createElement("canvas"), { width: 128, height: 128 });
|
|
138
|
+
const ctx = canvas.getContext("2d")!;
|
|
139
|
+
const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
|
|
140
|
+
gradient.addColorStop(0, "#fff");
|
|
141
|
+
gradient.addColorStop(0.3, "#999");
|
|
142
|
+
gradient.addColorStop(0.7, "#222");
|
|
143
|
+
gradient.addColorStop(1, "#000");
|
|
144
|
+
Object.assign(ctx, { fillStyle: gradient }).fillRect(0, 0, 128, 128);
|
|
145
|
+
return new CanvasTexture(canvas);
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
export function disposeGroundShadow(shadow: GroundShadow): void {
|
|
112
|
-
shadow.mesh.
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
[shadow.mesh, shadow.floor].forEach((mesh) => {
|
|
150
|
+
mesh.removeFromParent();
|
|
151
|
+
mesh.geometry.dispose();
|
|
152
|
+
mesh.material.alphaMap?.dispose();
|
|
153
|
+
mesh.material.dispose();
|
|
154
|
+
});
|
|
115
155
|
shadow.target.dispose();
|
|
116
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Jitters the alpha by about `levels` of the screen's 256 per pixel, so a faint gradient blended over the background doesn't show the 8-bit steps of
|
|
160
|
+
* the blend; three's `dithering` jitters the color, which these blends' steps don't come from
|
|
161
|
+
*/
|
|
162
|
+
function ditherAlpha(material: MeshBasicMaterial, levels: number): MeshBasicMaterial {
|
|
163
|
+
material.onBeforeCompile = (shader) => {
|
|
164
|
+
shader.fragmentShader = shader.fragmentShader.replace(
|
|
165
|
+
"#include <dithering_fragment>",
|
|
166
|
+
`gl_FragColor.a = clamp( gl_FragColor.a + ( rand( gl_FragCoord.xy ) - 0.5 ) * ${(levels / 255).toFixed(5)}, 0.0, 1.0 );`
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
return material;
|
|
170
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Object3D
|
|
1
|
+
import type { Object3D } from "three";
|
|
2
2
|
import type { Group, Relation, Scene, Vec3, View } from "../../../abstract-3d.js";
|
|
3
3
|
import type { MaterialState } from "../appearance/appearance.js";
|
|
4
4
|
import type { BufferZones, CameraOptions } from "../camera/camera.js";
|
|
@@ -34,21 +34,22 @@ 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;
|
|
41
41
|
readonly fitPadding?: number;
|
|
42
42
|
readonly controls?: ControlsOptions;
|
|
43
43
|
readonly gizmo?: ControlsHelper;
|
|
44
|
-
/**
|
|
44
|
+
/** Lights added to the photographed studio and key light that light the scene; none by default */
|
|
45
45
|
readonly lights?: ReadonlyArray<Object3D> | ((scene: Scene) => ReadonlyArray<Object3D>);
|
|
46
|
-
/**
|
|
47
|
-
readonly environment?: Texture | "studio" | "room" | null;
|
|
46
|
+
/** Of the studio's light, 0.5 by default, the key light giving the rest */
|
|
48
47
|
readonly environmentIntensity?: number;
|
|
49
48
|
/** A soft shadow on the ground under the scene, rendered once per build; on by default */
|
|
50
49
|
readonly groundShadow?: boolean;
|
|
51
|
-
/** The
|
|
50
|
+
/** The solids' shadows on themselves, e.g. under a roof, cast by the key light from the upper left front; on by default */
|
|
51
|
+
readonly shadows?: boolean;
|
|
52
|
+
/** 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 */
|
|
52
53
|
readonly quality?: Quality;
|
|
53
54
|
readonly useAlphaTest?: boolean;
|
|
54
55
|
readonly materialStateImages?: Record<string, string>;
|
|
@@ -88,9 +89,9 @@ export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: Readonly
|
|
|
88
89
|
controls: ["controls"],
|
|
89
90
|
gizmo: ["gizmo"],
|
|
90
91
|
lights: ["lights"],
|
|
91
|
-
environment: ["lights"],
|
|
92
92
|
environmentIntensity: ["lights"],
|
|
93
93
|
groundShadow: ["shadow"],
|
|
94
|
+
shadows: ["shadow"],
|
|
94
95
|
quality: ["quality"],
|
|
95
96
|
popovers: ["overlays"],
|
|
96
97
|
getTooltips: ["overlays"],
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
|
-
AmbientLight,
|
|
3
2
|
type BufferGeometry,
|
|
4
|
-
CanvasTexture,
|
|
5
|
-
DirectionalLight,
|
|
6
|
-
EquirectangularReflectionMapping,
|
|
7
3
|
Group,
|
|
8
4
|
NeutralToneMapping,
|
|
9
|
-
type Object3D,
|
|
10
5
|
OrthographicCamera,
|
|
11
6
|
PerspectiveCamera,
|
|
12
|
-
PMREMGenerator,
|
|
13
7
|
Raycaster,
|
|
14
8
|
Scene as ThreeScene,
|
|
15
|
-
SRGBColorSpace,
|
|
16
9
|
type Texture,
|
|
17
10
|
Vector2,
|
|
18
11
|
Vector3,
|
|
19
12
|
WebGLRenderer,
|
|
20
13
|
} from "three";
|
|
21
14
|
import type { ViewportGizmo } from "three-viewport-gizmo";
|
|
22
|
-
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
|
|
23
15
|
import type { StructureState } from "../appearance/appearance.js";
|
|
24
16
|
import type { BuiltScene } from "../build/build.js";
|
|
25
17
|
import { animateScene, buildScene, cullScene, disposeScene, getPickables, getPickedItem, paintScene } from "../build/build.js";
|
|
@@ -34,6 +26,7 @@ import type { CompiledScene } from "../compile/compile.js";
|
|
|
34
26
|
import { compileScene } from "../compile/compile.js";
|
|
35
27
|
import type { TierGovernor } from "../quality/quality.js";
|
|
36
28
|
import { getPixelRatio, recordFrame } from "../quality/quality.js";
|
|
29
|
+
import { createKeyLight, disposeKeyLight } from "../shadow/key-light.js";
|
|
37
30
|
import type { GroundShadow } from "../shadow/shadow.js";
|
|
38
31
|
import { createGroundShadow, disposeGroundShadow } from "../shadow/shadow.js";
|
|
39
32
|
import type { GlyphAtlas } from "../text/text.js";
|
|
@@ -80,8 +73,11 @@ export type Viewer<Content> = {
|
|
|
80
73
|
built?: BuiltScene;
|
|
81
74
|
gizmo?: ViewportGizmo;
|
|
82
75
|
shadow?: GroundShadow;
|
|
76
|
+
/** The key light that casts the solids' shadows on themselves */
|
|
77
|
+
keyLight?: Group;
|
|
83
78
|
governor?: TierGovernor;
|
|
84
|
-
|
|
79
|
+
/** The studio environment, loading and once loaded */
|
|
80
|
+
readonly studio: { loading?: Promise<unknown>; texture?: Texture };
|
|
85
81
|
/** The scene is built and its shaders compiled */
|
|
86
82
|
loaded: boolean;
|
|
87
83
|
fitted: boolean;
|
|
@@ -103,6 +99,8 @@ export function createViewer<Content>(container: HTMLElement, options: ViewerOpt
|
|
|
103
99
|
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
104
100
|
Object.assign(renderer, { toneMapping: NeutralToneMapping });
|
|
105
101
|
renderer.setClearColor(0x000000, 0);
|
|
102
|
+
// Rendered when the scene is built, not each frame
|
|
103
|
+
Object.assign(renderer.shadowMap, { enabled: true, autoUpdate: false });
|
|
106
104
|
renderer.info.autoReset = false;
|
|
107
105
|
const cameras = { Perspective: new PerspectiveCamera(45), Orthographic: new OrthographicCamera() };
|
|
108
106
|
const camera = cameras[options.camera?.type ?? "Perspective"];
|
|
@@ -128,7 +126,7 @@ export function createViewer<Content>(container: HTMLElement, options: ViewerOpt
|
|
|
128
126
|
pending: new Set(["compile", "build", "camera", "controls", "gizmo", "lights", "quality", "paint", "overlays"]),
|
|
129
127
|
stats: { frames: 0, frameMs: 0, drawCalls: 0, pickMs: 0, paintMs: 0 },
|
|
130
128
|
structure: { activeComponents: options.activeComponents, relationsActive: options.activeRelations !== undefined },
|
|
131
|
-
|
|
129
|
+
studio: {},
|
|
132
130
|
loaded: false,
|
|
133
131
|
fitted: false,
|
|
134
132
|
disposed: false,
|
|
@@ -172,7 +170,10 @@ export function disposeViewer<Content>(v: Viewer<Content>): void {
|
|
|
172
170
|
if (v.shadow) {
|
|
173
171
|
disposeGroundShadow(v.shadow);
|
|
174
172
|
}
|
|
175
|
-
|
|
173
|
+
if (v.keyLight) {
|
|
174
|
+
disposeKeyLight(v.keyLight);
|
|
175
|
+
}
|
|
176
|
+
v.studio.texture?.dispose();
|
|
176
177
|
v.geometries.forEach((g) => g.dispose());
|
|
177
178
|
v.atlas.texture.dispose();
|
|
178
179
|
disposeTextures(v.textures);
|
|
@@ -198,17 +199,26 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
|
|
|
198
199
|
disposeGroundShadow(v.shadow);
|
|
199
200
|
}
|
|
200
201
|
v.shadow = v.built && options.groundShadow !== false ? createGroundShadow(v.renderer, v.scene, v.built) : undefined;
|
|
202
|
+
if (v.keyLight) {
|
|
203
|
+
disposeKeyLight(v.keyLight);
|
|
204
|
+
}
|
|
205
|
+
v.keyLight = v.built ? createKeyLight(v.built, options.shadows !== false) : undefined;
|
|
206
|
+
if (v.keyLight) {
|
|
207
|
+
v.scene.add(v.keyLight);
|
|
208
|
+
}
|
|
209
|
+
// The shadow map is rendered once, as the scene doesn't move, or each frame while something animates
|
|
210
|
+
Object.assign(v.renderer.shadowMap, { needsUpdate: true, autoUpdate: !!v.built && v.built.animatedItems.length > 0 });
|
|
201
211
|
}
|
|
202
212
|
if (changes.has("camera")) {
|
|
203
213
|
const { near = 0.1, far = 2000, ...rest } = options.camera ?? { type: "Perspective" };
|
|
204
214
|
v.camera = v.cameras[rest.type];
|
|
205
|
-
Object.assign(v.camera, { near, far, ...(
|
|
215
|
+
Object.assign(v.camera, { near, far, ...(rest.type === "Perspective" ? { fov: rest.fov ?? 45 } : {}) });
|
|
206
216
|
v.controls.orbit.object = v.camera;
|
|
207
217
|
}
|
|
208
218
|
if (changes.has("controls")) {
|
|
209
219
|
configureControls(v.controls, options.controls ?? {});
|
|
210
220
|
}
|
|
211
|
-
// As the r3f renderer, the scene is fitted at first, and for a new camera or canvas
|
|
221
|
+
// As the r3f renderer, the scene is fitted at first, and for a new camera or canvas aspect
|
|
212
222
|
if ((changes.has("build") && !v.fitted) || changes.has("camera")) {
|
|
213
223
|
fitPose(v);
|
|
214
224
|
}
|
|
@@ -219,7 +229,7 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
|
|
|
219
229
|
updateLights(v);
|
|
220
230
|
}
|
|
221
231
|
if (changes.has("quality")) {
|
|
222
|
-
const quality = options.quality ?? "
|
|
232
|
+
const quality = options.quality ?? "high";
|
|
223
233
|
v.governor = { quality, tier: quality === "auto" ? "high" : quality, sum: 0, count: 0 };
|
|
224
234
|
v.renderer.setPixelRatio(getPixelRatio(v.governor.tier, window.devicePixelRatio));
|
|
225
235
|
}
|
|
@@ -260,9 +270,10 @@ function rebuild<Content>(v: Viewer<Content>): void {
|
|
|
260
270
|
if (v.built) {
|
|
261
271
|
v.scene.add(v.built.root);
|
|
262
272
|
v.built.dimensions.visible = options.showDimensions ?? true;
|
|
263
|
-
// Compiled in parallel where the driver can, as three.js's compileAsync example
|
|
264
|
-
v
|
|
265
|
-
.
|
|
273
|
+
// Compiled in parallel where the driver can, as three.js's compileAsync example, and after the studio arrived, which they use
|
|
274
|
+
loadStudio(v)
|
|
275
|
+
.catch(() => undefined)
|
|
276
|
+
.then(() => v.renderer.compileAsync(v.scene, v.camera))
|
|
266
277
|
.then(() => onLoaded(v))
|
|
267
278
|
.catch(() => undefined);
|
|
268
279
|
}
|
|
@@ -315,21 +326,32 @@ function renderFrame<Content>(v: Viewer<Content>, now: number): void {
|
|
|
315
326
|
}
|
|
316
327
|
}
|
|
317
328
|
|
|
329
|
+
/** As the r3f renderer, a resize refits only when the canvas' aspect changes */
|
|
318
330
|
function onResize<Content>(v: Viewer<Content>, rect: DOMRectReadOnly): void {
|
|
331
|
+
const aspectChanged = rect.width / rect.height !== v.size.width / v.size.height;
|
|
319
332
|
Object.assign(v.size, { width: rect.width, height: rect.height });
|
|
320
333
|
v.renderer.setSize(rect.width, rect.height, false);
|
|
321
334
|
v.overlays.renderer.setSize(rect.width, rect.height);
|
|
322
335
|
v.gizmo?.update();
|
|
323
|
-
|
|
324
|
-
|
|
336
|
+
if (aspectChanged) {
|
|
337
|
+
fitPose(v);
|
|
338
|
+
}
|
|
339
|
+
// Resizing cleared the canvas; rendered now, before the browser paints, instead of the next frame, so it never shows blank
|
|
340
|
+
if (v.loop.frame) {
|
|
341
|
+
cancelAnimationFrame(v.loop.frame);
|
|
342
|
+
v.loop.frame = 0;
|
|
343
|
+
}
|
|
344
|
+
if (rect.width > 0 && rect.height > 0 && !v.disposed) {
|
|
345
|
+
renderFrame(v, performance.now());
|
|
346
|
+
}
|
|
325
347
|
}
|
|
326
348
|
|
|
327
|
-
/** Fits the projection to the scene, and moves the camera to look
|
|
349
|
+
/** 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 */
|
|
328
350
|
function fitPose<Content>(v: Viewer<Content>): void {
|
|
329
351
|
const placement = fitScene(v);
|
|
330
352
|
if (placement) {
|
|
331
353
|
const { x, y, z } = placement.direction;
|
|
332
|
-
setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance),
|
|
354
|
+
setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance), v.controls.orbit.target);
|
|
333
355
|
v.fitted = true;
|
|
334
356
|
}
|
|
335
357
|
}
|
|
@@ -345,6 +367,19 @@ function updateGizmo<Content>(v: Viewer<Content>): void {
|
|
|
345
367
|
v.gizmo = gizmo && createGizmo(v.camera, v.renderer, v.container, gizmo).attachControls(v.controls.orbit);
|
|
346
368
|
v.gizmo?.addEventListener("change", () => invalidate(v));
|
|
347
369
|
v.gizmo?.addEventListener("start", () => onGizmoStart(v));
|
|
370
|
+
// Its labels are drawn once, so ones drawn before the page's web fonts loaded are redrawn when they have
|
|
371
|
+
const created = v.gizmo;
|
|
372
|
+
if (created && document.fonts.status !== "loaded") {
|
|
373
|
+
document.fonts.ready.then(() => redrawGizmo(v, created)).catch(() => undefined);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Unless the gizmo was replaced or the viewer disposed meanwhile */
|
|
378
|
+
function redrawGizmo<Content>(v: Viewer<Content>, gizmo: ViewportGizmo): void {
|
|
379
|
+
if (v.gizmo === gizmo && !v.disposed) {
|
|
380
|
+
gizmo.set(gizmo.options);
|
|
381
|
+
invalidate(v);
|
|
382
|
+
}
|
|
348
383
|
}
|
|
349
384
|
|
|
350
385
|
/**
|
|
@@ -459,63 +494,26 @@ function getGroupArgs<Content>(
|
|
|
459
494
|
}
|
|
460
495
|
}
|
|
461
496
|
|
|
497
|
+
/** The scene is lit by a photographed studio as its environment; apps can add their own lights */
|
|
462
498
|
function updateLights<Content>(v: Viewer<Content>): void {
|
|
463
|
-
const { scene, lights,
|
|
499
|
+
const { scene, lights, environmentIntensity = studioIntensity } = v.options;
|
|
464
500
|
v.lights.clear();
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
v.environments[environment] ??= createEnvironment(environment, v.renderer);
|
|
469
|
-
}
|
|
470
|
-
Object.assign(v.scene, { environment: typeof environment === "string" ? v.environments[environment] : environment, environmentIntensity });
|
|
501
|
+
(typeof lights === "function" ? (scene ? lights(scene) : []) : (lights ?? [])).forEach((o) => v.lights.add(o));
|
|
502
|
+
v.scene.environmentIntensity = environmentIntensity;
|
|
503
|
+
loadStudio(v).catch(() => undefined);
|
|
471
504
|
}
|
|
472
505
|
|
|
473
|
-
/**
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return
|
|
484
|
-
new AmbientLight(0xffffff, 0.2),
|
|
485
|
-
...directional.map(([x, y, z, i]) => {
|
|
486
|
-
const light = new DirectionalLight(0xffffff, i);
|
|
487
|
-
light.position.set(x, y, z);
|
|
488
|
-
return light;
|
|
489
|
-
}),
|
|
490
|
-
];
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Studio: an equirectangular gradient from a white ceiling to a dark floor, with three softboxes 20-50° up, so faces are brighter from above than
|
|
495
|
-
* from below and metal profiles and panels get reflections. Room: three.js's RoomEnvironment through PMREM, as its examples light models
|
|
496
|
-
*/
|
|
497
|
-
function createEnvironment(kind: "studio" | "room", renderer: WebGLRenderer): Texture {
|
|
498
|
-
if (kind === "room") {
|
|
499
|
-
const pmrem = new PMREMGenerator(renderer);
|
|
500
|
-
const texture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
501
|
-
pmrem.dispose();
|
|
502
|
-
return texture;
|
|
503
|
-
}
|
|
504
|
-
const canvas = Object.assign(document.createElement("canvas"), { width: 1024, height: 512 });
|
|
505
|
-
const ctx = canvas.getContext("2d")!;
|
|
506
|
-
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
|
507
|
-
gradient.addColorStop(0, "#ffffff");
|
|
508
|
-
gradient.addColorStop(0.45, "#d8d8d8");
|
|
509
|
-
gradient.addColorStop(0.55, "#8a8a8a");
|
|
510
|
-
gradient.addColorStop(1, "#5a5a5a");
|
|
511
|
-
Object.assign(ctx, { fillStyle: gradient }).fillRect(0, 0, 1024, 512);
|
|
512
|
-
ctx.fillStyle = "#ffffff";
|
|
513
|
-
[
|
|
514
|
-
[80, 120],
|
|
515
|
-
[400, 90],
|
|
516
|
-
[700, 140],
|
|
517
|
-
].forEach(([x, width]) => ctx.fillRect(x!, 110, width!, 90));
|
|
518
|
-
return Object.assign(new CanvasTexture(canvas), { mapping: EquirectangularReflectionMapping, colorSpace: SRGBColorSpace });
|
|
506
|
+
/** The studio, loaded once, its own chunk; set when it arrives, so the scene's shaders are compiled after it, with it */
|
|
507
|
+
function loadStudio<Content>(v: Viewer<Content>): Promise<unknown> {
|
|
508
|
+
v.studio.loading ??= import("../environment/studio.js")
|
|
509
|
+
.then(({ loadStudio: load }) => load())
|
|
510
|
+
.then((texture) => {
|
|
511
|
+
v.studio.texture = texture;
|
|
512
|
+
v.scene.environment = texture;
|
|
513
|
+
invalidate(v);
|
|
514
|
+
return texture;
|
|
515
|
+
});
|
|
516
|
+
return v.studio.loading;
|
|
519
517
|
}
|
|
520
518
|
|
|
521
519
|
function updateOverlays<Content>(v: Viewer<Content>): void {
|
|
@@ -539,5 +537,8 @@ function updateOverlays<Content>(v: Viewer<Content>): void {
|
|
|
539
537
|
setOverlays(v.overlays, overlays, options.onOverlays);
|
|
540
538
|
}
|
|
541
539
|
|
|
540
|
+
/** The studio's share of the light, the key light giving the rest, so the key light's shadows show */
|
|
541
|
+
const studioIntensity = 0.5;
|
|
542
|
+
|
|
542
543
|
const raycaster = new Raycaster();
|
|
543
544
|
const ndc = new Vector2();
|