3d-spinner 0.9.0 → 0.9.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/README.md +19 -1
- package/dist/animations/object-motion.d.ts +4 -1
- package/dist/animations/object-motion.js +2 -1
- package/dist/animations/spin.d.ts +4 -1
- package/dist/animations/spin.js +2 -1
- package/dist/engines/little-3d-engine/core/mesh.d.ts +18 -0
- package/dist/engines/little-3d-engine/little-3d-engine.d.ts +10 -3
- package/dist/engines/little-3d-engine/little-3d-engine.js +12 -8
- package/dist/engines/little-3d-engine/renderer.d.ts +15 -1
- package/dist/engines/little-3d-engine/renderer.js +36 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d.d.ts +1 -1
- package/dist/engines/little-3d-engine/renderers/canvas2d.js +22 -2
- package/dist/engines/little-3d-engine/renderers/webgl.d.ts +1 -1
- package/dist/engines/little-3d-engine/renderers/webgl.js +38 -1
- package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +3 -1
- package/dist/engines/little-3d-engine/renderers/webgpu.js +70 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 3d-spinner
|
|
1
|
+
# 3d-spinner and beyond
|
|
2
2
|
|
|
3
3
|
[](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/3d-spinner)
|
|
@@ -145,6 +145,24 @@ do not use is never fetched.
|
|
|
145
145
|
new SpinAnimation({ backend: "webgl" }); // "canvas2d" (default), "webgl", or "webgpu"
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
Renderer-specific features can look different between Canvas 2D, WebGL, and WebGPU. In
|
|
149
|
+
particular, transparent shapes are an approximate visual effect rather than a pixel-identical
|
|
150
|
+
cross-renderer result.
|
|
151
|
+
|
|
152
|
+
Use `transparency.mode` to choose visible-front-face transparency or a two-pass transparent-solid
|
|
153
|
+
effect. Opacity defaults to `0.35` for one-sided rendering. Two-sided rendering defaults to
|
|
154
|
+
front `0.56` and back `0.84`.
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
new SpinAnimation({
|
|
158
|
+
backend: "webgl",
|
|
159
|
+
transparency: { mode: "two-sided", opacity: 0.6 }, // front 0.6, back 0.4
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For two-sided rendering, explicit `frontOpacity` and `backOpacity` values override the shorthand
|
|
164
|
+
for their respective sides.
|
|
165
|
+
|
|
148
166
|
## The engine
|
|
149
167
|
|
|
150
168
|
The renderer is a small, self-contained 3D engine, exported on its own in case you want it
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
|
|
2
|
-
import { type Backend, type Mesh } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
2
|
+
import { type Backend, type Mesh, type Transparency } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
3
|
import type { MotionController } from "../motion/controller.js";
|
|
4
4
|
import { type ObjectMotionTransitionConfig } from "../motion/transitions.js";
|
|
5
5
|
/** Which local axis the object's nose points down, used to correct a model that moves backwards or sideways. */
|
|
@@ -31,6 +31,8 @@ export interface ObjectMotionOptions {
|
|
|
31
31
|
color?: string;
|
|
32
32
|
/** Rendering backend. Default `"canvas2d"`. */
|
|
33
33
|
backend?: Backend;
|
|
34
|
+
/** Optional one-sided or two-sided mesh transparency. */
|
|
35
|
+
transparency?: Transparency;
|
|
34
36
|
/** Uniform object size after centering. Default `1`. */
|
|
35
37
|
size?: number;
|
|
36
38
|
/** The object's nose axis. Set this to correct a model that moves backwards or sideways. Default `"+x"`. */
|
|
@@ -65,6 +67,7 @@ export declare class ObjectMotionAnimation implements SpinnerAnimation {
|
|
|
65
67
|
private readonly mesh;
|
|
66
68
|
private readonly motion;
|
|
67
69
|
private readonly backend?;
|
|
70
|
+
private readonly transparency?;
|
|
68
71
|
private readonly labelText?;
|
|
69
72
|
private readonly tailCount;
|
|
70
73
|
private readonly tailGap;
|
|
@@ -159,6 +159,7 @@ export class ObjectMotionAnimation {
|
|
|
159
159
|
this.mesh = applyColor(facing, options.color ?? "#cbd5e1");
|
|
160
160
|
this.motion = options.motion;
|
|
161
161
|
this.backend = options.backend;
|
|
162
|
+
this.transparency = options.transparency;
|
|
162
163
|
this.labelText = options.label;
|
|
163
164
|
this.tailCount = Math.max(0, Math.floor(options.tail?.count ?? 0));
|
|
164
165
|
this.tailGap = Math.max(0, options.tail?.gapMs ?? 0);
|
|
@@ -186,7 +187,7 @@ export class ObjectMotionAnimation {
|
|
|
186
187
|
camera: { position: { x: 0, y: 0, z: 3 } },
|
|
187
188
|
});
|
|
188
189
|
for (let i = 0; i <= this.tailCount; i++) {
|
|
189
|
-
this.handles.push(engine.add(this.mesh));
|
|
190
|
+
this.handles.push(engine.add(this.mesh, { transparency: this.transparency }));
|
|
190
191
|
this.banks.push(0);
|
|
191
192
|
this.headings.push({ x: 1, y: 0, z: 0 });
|
|
192
193
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
|
|
2
|
-
import { type Backend, type Mesh } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
2
|
+
import { type Backend, type Mesh, type Transparency } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
3
|
import { type ProgressAnimationOptions } from "../progress-animation.js";
|
|
4
4
|
export interface SpinAnimationOptions {
|
|
5
5
|
/** Shape to spin: a mesh, or a factory that returns one. Default: a cube. */
|
|
@@ -12,6 +12,8 @@ export interface SpinAnimationOptions {
|
|
|
12
12
|
spinY?: number;
|
|
13
13
|
/** Rendering backend. Default `"canvas2d"`. */
|
|
14
14
|
backend?: Backend;
|
|
15
|
+
/** Optional one-sided or two-sided mesh transparency. */
|
|
16
|
+
transparency?: Transparency;
|
|
15
17
|
/**
|
|
16
18
|
* Enable the start/end pop and progress-driven scale, with an optional overlay
|
|
17
19
|
* label. Omit to spin at constant size with no progress reaction.
|
|
@@ -31,6 +33,7 @@ export declare class SpinAnimation implements SpinnerAnimation {
|
|
|
31
33
|
private readonly spinX;
|
|
32
34
|
private readonly spinY;
|
|
33
35
|
private readonly backend?;
|
|
36
|
+
private readonly transparency?;
|
|
34
37
|
private readonly progress?;
|
|
35
38
|
private exited;
|
|
36
39
|
constructor(options?: SpinAnimationOptions);
|
package/dist/animations/spin.js
CHANGED
|
@@ -36,6 +36,7 @@ export class SpinAnimation {
|
|
|
36
36
|
this.spinX = options.spinX ?? 0.0007;
|
|
37
37
|
this.spinY = options.spinY ?? 0.0011;
|
|
38
38
|
this.backend = options.backend;
|
|
39
|
+
this.transparency = options.transparency;
|
|
39
40
|
this.progress = options.progressAnimation
|
|
40
41
|
? new ProgressAnimation(options.progressAnimation)
|
|
41
42
|
: undefined;
|
|
@@ -46,7 +47,7 @@ export class SpinAnimation {
|
|
|
46
47
|
backend: this.backend,
|
|
47
48
|
camera: { position: { x: 0, y: 0, z: 2.8 } },
|
|
48
49
|
});
|
|
49
|
-
this.handle = engine.add(this.mesh);
|
|
50
|
+
this.handle = engine.add(this.mesh, { transparency: this.transparency });
|
|
50
51
|
this.engine = engine;
|
|
51
52
|
engine.mount(target).catch((error) => {
|
|
52
53
|
target.textContent = error instanceof Error ? error.message : String(error);
|
|
@@ -11,6 +11,24 @@ export interface Mesh {
|
|
|
11
11
|
vertices: Vec3[];
|
|
12
12
|
faces: Face[];
|
|
13
13
|
}
|
|
14
|
+
/** Draw only outward-facing transparent surfaces. */
|
|
15
|
+
export interface OneSidedTransparency {
|
|
16
|
+
mode: "one-sided";
|
|
17
|
+
/** Surface opacity from `0` (invisible) to `1` (opaque). Default `0.35`. */
|
|
18
|
+
opacity?: number;
|
|
19
|
+
}
|
|
20
|
+
/** Draw back surfaces before front surfaces to suggest a transparent solid. */
|
|
21
|
+
export interface TwoSidedTransparency {
|
|
22
|
+
mode: "two-sided";
|
|
23
|
+
/** Front opacity shorthand; back opacity is derived as two-thirds of front. */
|
|
24
|
+
opacity?: number;
|
|
25
|
+
/** Back-surface opacity from `0` to `1`. Default `0.84`. */
|
|
26
|
+
backOpacity?: number;
|
|
27
|
+
/** Front-surface opacity from `0` to `1`. Default `0.56`. */
|
|
28
|
+
frontOpacity?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Transparency mode for one mesh instance. */
|
|
31
|
+
export type Transparency = OneSidedTransparency | TwoSidedTransparency;
|
|
14
32
|
/** Position and orientation (Euler radians) applied to a mesh when rendered. */
|
|
15
33
|
export interface Transform {
|
|
16
34
|
position: Vec3;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CameraOptions } from "./core/camera.js";
|
|
2
2
|
import { type LightOptions } from "./core/light.js";
|
|
3
|
-
import { type Mesh, type Transform } from "./core/mesh.js";
|
|
3
|
+
import { type Mesh, type Transform, type Transparency } from "./core/mesh.js";
|
|
4
4
|
import { type Backend } from "./renderer.js";
|
|
5
5
|
/** Options for {@link Little3dEngine}. */
|
|
6
6
|
export interface Little3dEngineOptions {
|
|
@@ -15,9 +15,15 @@ export interface Little3dEngineOptions {
|
|
|
15
15
|
export interface MeshHandle {
|
|
16
16
|
readonly mesh: Mesh;
|
|
17
17
|
readonly transform: Transform;
|
|
18
|
+
/** Optional per-instance transparency. Mutate or replace it between frames. */
|
|
19
|
+
transparency?: Transparency;
|
|
18
20
|
/** Remove this mesh from the scene. */
|
|
19
21
|
remove(): void;
|
|
20
22
|
}
|
|
23
|
+
/** Initial state for one mesh instance. */
|
|
24
|
+
export interface MeshInstanceOptions extends Partial<Transform> {
|
|
25
|
+
transparency?: Transparency;
|
|
26
|
+
}
|
|
21
27
|
/**
|
|
22
28
|
* A minimal software/hardware 3D engine. It projects colored meshes with flat
|
|
23
29
|
* directional lighting through a swappable {@link Backend} renderer. Mount it
|
|
@@ -47,7 +53,7 @@ export declare class Little3dEngine {
|
|
|
47
53
|
*/
|
|
48
54
|
mount(target: HTMLElement): Promise<void>;
|
|
49
55
|
/** Add a mesh to the scene and return a handle for animating it. */
|
|
50
|
-
add(mesh: Mesh, init?:
|
|
56
|
+
add(mesh: Mesh, init?: MeshInstanceOptions): MeshHandle;
|
|
51
57
|
private resize;
|
|
52
58
|
/** Draw a single frame from the current scene state. */
|
|
53
59
|
render(): void;
|
|
@@ -69,7 +75,8 @@ export { icosphere } from "./shapes/icosphere.js";
|
|
|
69
75
|
export { octaSphere } from "./shapes/octa-sphere.js";
|
|
70
76
|
export { cubeSphere } from "./shapes/cube-sphere.js";
|
|
71
77
|
export { expandToTriangles } from "./core/geometry.js";
|
|
72
|
-
export type { Mesh, Face, Transform } from "./core/mesh.js";
|
|
78
|
+
export type { Mesh, Face, Transform, Transparency, OneSidedTransparency, TwoSidedTransparency, } from "./core/mesh.js";
|
|
73
79
|
export { transform } from "./core/mesh.js";
|
|
74
80
|
export type { Backend, Renderer, RenderFrame, RenderItem, RendererOptions } from "./renderer.js";
|
|
81
|
+
export { orderRenderItems } from "./renderer.js";
|
|
75
82
|
export { type Vec3, vec3, subtract, cross, dot, scale, normalize, } from "./core/math.js";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Camera } from "./core/camera.js";
|
|
2
2
|
import { Light } from "./core/light.js";
|
|
3
3
|
import { multiply, rotationX, rotationY, rotationZ, scaleMatrix, translation, } from "./core/math.js";
|
|
4
|
-
import { transform as makeTransform } from "./core/mesh.js";
|
|
5
|
-
import { createRenderer } from "./renderer.js";
|
|
4
|
+
import { transform as makeTransform, } from "./core/mesh.js";
|
|
5
|
+
import { createRenderer, orderRenderItems, } from "./renderer.js";
|
|
6
6
|
function modelMatrix(t) {
|
|
7
7
|
const rotation = multiply(rotationZ(t.rotation.z), multiply(rotationY(t.rotation.y), rotationX(t.rotation.x)));
|
|
8
8
|
return multiply(translation(t.position.x, t.position.y, t.position.z), multiply(rotation, scaleMatrix(t.scale)));
|
|
@@ -75,17 +75,18 @@ export class Little3dEngine {
|
|
|
75
75
|
}
|
|
76
76
|
/** Add a mesh to the scene and return a handle for animating it. */
|
|
77
77
|
add(mesh, init) {
|
|
78
|
-
const entry = {
|
|
79
|
-
this.scene.push(entry);
|
|
80
|
-
return {
|
|
78
|
+
const entry = {
|
|
81
79
|
mesh,
|
|
82
|
-
transform:
|
|
80
|
+
transform: makeTransform(init),
|
|
81
|
+
transparency: init?.transparency,
|
|
83
82
|
remove: () => {
|
|
84
83
|
const i = this.scene.indexOf(entry);
|
|
85
84
|
if (i >= 0)
|
|
86
85
|
this.scene.splice(i, 1);
|
|
87
86
|
},
|
|
88
87
|
};
|
|
88
|
+
this.scene.push(entry);
|
|
89
|
+
return entry;
|
|
89
90
|
}
|
|
90
91
|
resize() {
|
|
91
92
|
const canvas = this.canvas;
|
|
@@ -109,11 +110,13 @@ export class Little3dEngine {
|
|
|
109
110
|
const items = this.scene.map((entry) => ({
|
|
110
111
|
mesh: entry.mesh,
|
|
111
112
|
model: modelMatrix(entry.transform),
|
|
113
|
+
transparency: entry.transparency,
|
|
112
114
|
}));
|
|
115
|
+
const eye = this.camera.options.position;
|
|
113
116
|
this.renderer.render({
|
|
114
|
-
items,
|
|
117
|
+
items: orderRenderItems(items, eye),
|
|
115
118
|
viewProjection: this.camera.viewProjection(width / height),
|
|
116
|
-
eye
|
|
119
|
+
eye,
|
|
117
120
|
light: this.light.params,
|
|
118
121
|
width,
|
|
119
122
|
height,
|
|
@@ -164,4 +167,5 @@ export { octaSphere } from "./shapes/octa-sphere.js";
|
|
|
164
167
|
export { cubeSphere } from "./shapes/cube-sphere.js";
|
|
165
168
|
export { expandToTriangles } from "./core/geometry.js";
|
|
166
169
|
export { transform } from "./core/mesh.js";
|
|
170
|
+
export { orderRenderItems } from "./renderer.js";
|
|
167
171
|
export { vec3, subtract, cross, dot, scale, normalize, } from "./core/math.js";
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import type { LightParams } from "./core/light.js";
|
|
2
2
|
import type { Mat4, Vec3 } from "./core/math.js";
|
|
3
|
-
import type { Mesh } from "./core/mesh.js";
|
|
3
|
+
import type { Mesh, Transparency, TwoSidedTransparency } from "./core/mesh.js";
|
|
4
4
|
/** Rendering backend. Each is loaded on demand; unused ones are never fetched. */
|
|
5
5
|
export type Backend = "canvas2d" | "webgl" | "webgpu";
|
|
6
6
|
/** A mesh plus its world transform, ready to draw. */
|
|
7
7
|
export interface RenderItem {
|
|
8
8
|
mesh: Mesh;
|
|
9
9
|
model: Mat4;
|
|
10
|
+
transparency?: Transparency;
|
|
10
11
|
}
|
|
12
|
+
export declare const DEFAULT_ONE_SIDED_OPACITY = 0.35;
|
|
13
|
+
export declare const DEFAULT_BACK_OPACITY = 0.84;
|
|
14
|
+
export declare const DEFAULT_FRONT_OPACITY = 0.56;
|
|
15
|
+
/** Clamp an optional opacity to the range accepted by rendering backends. */
|
|
16
|
+
export declare function opacity(value: number | undefined, fallback: number): number;
|
|
17
|
+
/** Resolve two-sided defaults, shorthand, and explicit per-side overrides. */
|
|
18
|
+
export declare function resolveTwoSidedOpacity(transparency: TwoSidedTransparency): {
|
|
19
|
+
front: number;
|
|
20
|
+
back: number;
|
|
21
|
+
};
|
|
22
|
+
/** Draw opaque instances first, then transparent instances from farthest to nearest. */
|
|
23
|
+
export declare function orderRenderItems(items: ReadonlyArray<RenderItem>, eye: Vec3): RenderItem[];
|
|
11
24
|
/** Everything a renderer needs to draw one frame. */
|
|
12
25
|
export interface RenderFrame {
|
|
13
26
|
items: ReadonlyArray<RenderItem>;
|
|
@@ -28,6 +41,7 @@ export interface RendererOptions {
|
|
|
28
41
|
/**
|
|
29
42
|
* A pluggable drawing backend. The engine owns the canvas and sizing; a
|
|
30
43
|
* renderer only initializes its context, reacts to resizes, and draws frames.
|
|
44
|
+
* Renderer-specific features can produce intentional visual differences.
|
|
31
45
|
*/
|
|
32
46
|
export interface Renderer {
|
|
33
47
|
init(canvas: HTMLCanvasElement): void | Promise<void>;
|
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
export const DEFAULT_ONE_SIDED_OPACITY = 0.35;
|
|
2
|
+
export const DEFAULT_BACK_OPACITY = 0.84;
|
|
3
|
+
export const DEFAULT_FRONT_OPACITY = 0.56;
|
|
4
|
+
/** Clamp an optional opacity to the range accepted by rendering backends. */
|
|
5
|
+
export function opacity(value, fallback) {
|
|
6
|
+
return Math.max(0, Math.min(1, value ?? fallback));
|
|
7
|
+
}
|
|
8
|
+
/** Resolve two-sided defaults, shorthand, and explicit per-side overrides. */
|
|
9
|
+
export function resolveTwoSidedOpacity(transparency) {
|
|
10
|
+
const front = opacity(transparency.frontOpacity ?? transparency.opacity, DEFAULT_FRONT_OPACITY);
|
|
11
|
+
const backFallback = transparency.opacity === undefined
|
|
12
|
+
? DEFAULT_BACK_OPACITY
|
|
13
|
+
: front * (2 / 3);
|
|
14
|
+
return {
|
|
15
|
+
front,
|
|
16
|
+
back: opacity(transparency.backOpacity, backFallback),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Draw opaque instances first, then transparent instances from farthest to nearest. */
|
|
20
|
+
export function orderRenderItems(items, eye) {
|
|
21
|
+
const opaque = [];
|
|
22
|
+
const transparent = [];
|
|
23
|
+
for (const item of items) {
|
|
24
|
+
(item.transparency ? transparent : opaque).push(item);
|
|
25
|
+
}
|
|
26
|
+
transparent.sort((a, b) => {
|
|
27
|
+
const ax = a.model[12] - eye.x;
|
|
28
|
+
const ay = a.model[13] - eye.y;
|
|
29
|
+
const az = a.model[14] - eye.z;
|
|
30
|
+
const bx = b.model[12] - eye.x;
|
|
31
|
+
const by = b.model[13] - eye.y;
|
|
32
|
+
const bz = b.model[14] - eye.z;
|
|
33
|
+
return bx * bx + by * by + bz * bz - (ax * ax + ay * ay + az * az);
|
|
34
|
+
});
|
|
35
|
+
return opaque.concat(transparent);
|
|
36
|
+
}
|
|
1
37
|
/**
|
|
2
38
|
* Load and construct a renderer for `backend`. Each backend lives in its own
|
|
3
39
|
* module and is pulled in with a dynamic `import()`, so the bytes for the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Renderer, type RenderFrame, type RendererOptions } from "../renderer.js";
|
|
2
2
|
/** Software renderer: projects geometry on the CPU and fills 2D polygons. */
|
|
3
3
|
export declare class Canvas2DRenderer implements Renderer {
|
|
4
4
|
private readonly options;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { shadeColor } from "../core/light.js";
|
|
2
2
|
import { dot, cross, normalize, subtract, transformAffine, transformPoint } from "../core/math.js";
|
|
3
|
+
import { DEFAULT_ONE_SIDED_OPACITY, opacity, resolveTwoSidedOpacity, } from "../renderer.js";
|
|
3
4
|
/** Software renderer: projects geometry on the CPU and fills 2D polygons. */
|
|
4
5
|
export class Canvas2DRenderer {
|
|
5
6
|
constructor(options = {}) {
|
|
@@ -27,13 +28,25 @@ export class Canvas2DRenderer {
|
|
|
27
28
|
const polygons = [];
|
|
28
29
|
for (const item of frame.items) {
|
|
29
30
|
const world = item.mesh.vertices.map((v) => transformAffine(item.model, v));
|
|
31
|
+
const twoSidedOpacity = item.transparency?.mode === "two-sided"
|
|
32
|
+
? resolveTwoSidedOpacity(item.transparency)
|
|
33
|
+
: undefined;
|
|
30
34
|
for (const face of item.mesh.faces) {
|
|
31
35
|
const a = world[face.indices[0]];
|
|
32
36
|
const b = world[face.indices[1]];
|
|
33
37
|
const c = world[face.indices[2]];
|
|
34
38
|
const normal = normalize(cross(subtract(b, a), subtract(c, a)));
|
|
35
|
-
|
|
39
|
+
const frontFacing = dot(normal, subtract(frame.eye, a)) > 0;
|
|
40
|
+
const transparency = item.transparency;
|
|
41
|
+
if (!frontFacing && transparency?.mode !== "two-sided")
|
|
36
42
|
continue;
|
|
43
|
+
let faceOpacity = 1;
|
|
44
|
+
if (transparency?.mode === "one-sided") {
|
|
45
|
+
faceOpacity = opacity(transparency.opacity, DEFAULT_ONE_SIDED_OPACITY);
|
|
46
|
+
}
|
|
47
|
+
else if (twoSidedOpacity) {
|
|
48
|
+
faceOpacity = frontFacing ? twoSidedOpacity.front : twoSidedOpacity.back;
|
|
49
|
+
}
|
|
37
50
|
const points = face.indices.map((i) => {
|
|
38
51
|
const ndc = transformPoint(frame.viewProjection, world[i]);
|
|
39
52
|
return {
|
|
@@ -47,7 +60,12 @@ export class Canvas2DRenderer {
|
|
|
47
60
|
depth += dot(d, d);
|
|
48
61
|
}
|
|
49
62
|
depth /= face.indices.length;
|
|
50
|
-
polygons.push({
|
|
63
|
+
polygons.push({
|
|
64
|
+
points,
|
|
65
|
+
color: shadeColor(normal, face.color, frame.light),
|
|
66
|
+
depth,
|
|
67
|
+
opacity: faceOpacity,
|
|
68
|
+
});
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
polygons.sort((p, q) => q.depth - p.depth);
|
|
@@ -63,9 +81,11 @@ export class Canvas2DRenderer {
|
|
|
63
81
|
ctx.fillStyle = poly.color;
|
|
64
82
|
ctx.strokeStyle = poly.color;
|
|
65
83
|
ctx.lineWidth = 1;
|
|
84
|
+
ctx.globalAlpha = poly.opacity;
|
|
66
85
|
ctx.fill();
|
|
67
86
|
ctx.stroke();
|
|
68
87
|
}
|
|
88
|
+
ctx.globalAlpha = 1;
|
|
69
89
|
}
|
|
70
90
|
destroy() {
|
|
71
91
|
this.ctx = undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Renderer, type RenderFrame, type RendererOptions } from "../renderer.js";
|
|
2
2
|
/** Hardware renderer using WebGL2: GPU transforms with a real depth buffer. */
|
|
3
3
|
export declare class WebGLRenderer implements Renderer {
|
|
4
4
|
private gl?;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expandToTriangles, parseColor } from "../core/geometry.js";
|
|
2
|
+
import { DEFAULT_ONE_SIDED_OPACITY, opacity, resolveTwoSidedOpacity, } from "../renderer.js";
|
|
2
3
|
const VERTEX_SHADER = `#version 300 es
|
|
3
4
|
in vec3 aPos;
|
|
4
5
|
in vec3 aNormal;
|
|
@@ -19,11 +20,12 @@ in vec3 vColor;
|
|
|
19
20
|
uniform vec3 uToLight;
|
|
20
21
|
uniform float uIntensity;
|
|
21
22
|
uniform float uAmbient;
|
|
23
|
+
uniform float uOpacity;
|
|
22
24
|
out vec4 fragColor;
|
|
23
25
|
void main() {
|
|
24
26
|
float lambert = max(dot(normalize(vNormal), normalize(uToLight)), 0.0);
|
|
25
27
|
float brightness = clamp(uAmbient + uIntensity * lambert, 0.0, 1.0);
|
|
26
|
-
fragColor = vec4(vColor * brightness,
|
|
28
|
+
fragColor = vec4(vColor * brightness, uOpacity);
|
|
27
29
|
}`;
|
|
28
30
|
function compile(gl, type, source) {
|
|
29
31
|
const shader = gl.createShader(type);
|
|
@@ -71,6 +73,7 @@ export class WebGLRenderer {
|
|
|
71
73
|
uToLight: gl.getUniformLocation(this.program, "uToLight"),
|
|
72
74
|
uIntensity: gl.getUniformLocation(this.program, "uIntensity"),
|
|
73
75
|
uAmbient: gl.getUniformLocation(this.program, "uAmbient"),
|
|
76
|
+
uOpacity: gl.getUniformLocation(this.program, "uOpacity"),
|
|
74
77
|
};
|
|
75
78
|
gl.enable(gl.DEPTH_TEST);
|
|
76
79
|
gl.enable(gl.CULL_FACE);
|
|
@@ -122,12 +125,46 @@ export class WebGLRenderer {
|
|
|
122
125
|
gl.uniform3f(loc.uToLight, frame.light.toLight.x, frame.light.toLight.y, frame.light.toLight.z);
|
|
123
126
|
gl.uniform1f(loc.uIntensity, frame.light.intensity);
|
|
124
127
|
gl.uniform1f(loc.uAmbient, frame.light.ambient);
|
|
128
|
+
gl.disable(gl.BLEND);
|
|
129
|
+
gl.depthMask(true);
|
|
130
|
+
gl.cullFace(gl.BACK);
|
|
125
131
|
for (const item of frame.items) {
|
|
132
|
+
if (item.transparency)
|
|
133
|
+
continue;
|
|
126
134
|
const mesh = this.buffers(item.mesh);
|
|
127
135
|
gl.uniformMatrix4fv(loc.uModel, false, new Float32Array(item.model));
|
|
136
|
+
gl.uniform1f(loc.uOpacity, 1);
|
|
128
137
|
gl.bindVertexArray(mesh.vao);
|
|
129
138
|
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
|
|
130
139
|
}
|
|
140
|
+
gl.enable(gl.BLEND);
|
|
141
|
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
142
|
+
gl.depthMask(false);
|
|
143
|
+
for (const item of frame.items) {
|
|
144
|
+
const transparency = item.transparency;
|
|
145
|
+
if (!transparency)
|
|
146
|
+
continue;
|
|
147
|
+
const mesh = this.buffers(item.mesh);
|
|
148
|
+
gl.uniformMatrix4fv(loc.uModel, false, new Float32Array(item.model));
|
|
149
|
+
gl.bindVertexArray(mesh.vao);
|
|
150
|
+
if (transparency.mode === "two-sided") {
|
|
151
|
+
const resolved = resolveTwoSidedOpacity(transparency);
|
|
152
|
+
gl.cullFace(gl.FRONT);
|
|
153
|
+
gl.uniform1f(loc.uOpacity, resolved.back);
|
|
154
|
+
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
|
|
155
|
+
gl.cullFace(gl.BACK);
|
|
156
|
+
gl.uniform1f(loc.uOpacity, resolved.front);
|
|
157
|
+
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
gl.cullFace(gl.BACK);
|
|
161
|
+
gl.uniform1f(loc.uOpacity, opacity(transparency.opacity, DEFAULT_ONE_SIDED_OPACITY));
|
|
162
|
+
gl.drawArrays(gl.TRIANGLES, 0, mesh.count);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
gl.depthMask(true);
|
|
166
|
+
gl.disable(gl.BLEND);
|
|
167
|
+
gl.cullFace(gl.BACK);
|
|
131
168
|
gl.bindVertexArray(null);
|
|
132
169
|
}
|
|
133
170
|
destroy() {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Renderer, type RenderFrame, type RendererOptions } from "../renderer.js";
|
|
2
2
|
/** Hardware renderer using WebGPU: GPU transforms with a real depth buffer. */
|
|
3
3
|
export declare class WebGPURenderer implements Renderer {
|
|
4
4
|
private canvas?;
|
|
5
5
|
private device;
|
|
6
6
|
private context;
|
|
7
7
|
private pipeline;
|
|
8
|
+
private transparentBackPipeline;
|
|
9
|
+
private transparentFrontPipeline;
|
|
8
10
|
private uniformBuffer;
|
|
9
11
|
private uniformCapacity;
|
|
10
12
|
private depthTexture;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expandToTriangles, parseColor } from "../core/geometry.js";
|
|
2
2
|
import { multiply } from "../core/math.js";
|
|
3
|
+
import { DEFAULT_ONE_SIDED_OPACITY, opacity, resolveTwoSidedOpacity, } from "../renderer.js";
|
|
3
4
|
const WGSL = `
|
|
4
5
|
struct Uniforms {
|
|
5
6
|
viewProj: mat4x4<f32>,
|
|
@@ -29,7 +30,7 @@ fn vs(@location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2)
|
|
|
29
30
|
fn fs(in: VSOut) -> @location(0) vec4<f32> {
|
|
30
31
|
let lambert = max(dot(normalize(in.normal), normalize(u.toLight.xyz)), 0.0);
|
|
31
32
|
let brightness = clamp(u.params.y + u.params.x * lambert, 0.0, 1.0);
|
|
32
|
-
return vec4<f32>(in.color * brightness,
|
|
33
|
+
return vec4<f32>(in.color * brightness, u.params.z);
|
|
33
34
|
}
|
|
34
35
|
`;
|
|
35
36
|
// Maps OpenGL clip space (z in -1..1) to WebGPU clip space (z in 0..1).
|
|
@@ -84,17 +85,37 @@ export class WebGPURenderer {
|
|
|
84
85
|
arrayStride: 12,
|
|
85
86
|
attributes: [{ shaderLocation: location, offset: 0, format: "float32x3" }],
|
|
86
87
|
});
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [layout] });
|
|
89
|
+
const blend = {
|
|
90
|
+
color: {
|
|
91
|
+
srcFactor: "src-alpha",
|
|
92
|
+
dstFactor: "one-minus-src-alpha",
|
|
93
|
+
operation: "add",
|
|
94
|
+
},
|
|
95
|
+
alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
|
|
96
|
+
};
|
|
97
|
+
const pipeline = (cullMode, transparent) => device.createRenderPipeline({
|
|
98
|
+
layout: pipelineLayout,
|
|
89
99
|
vertex: {
|
|
90
100
|
module,
|
|
91
101
|
entryPoint: "vs",
|
|
92
102
|
buffers: [vertexBuffer(0), vertexBuffer(1), vertexBuffer(2)],
|
|
93
103
|
},
|
|
94
|
-
fragment: {
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
fragment: {
|
|
105
|
+
module,
|
|
106
|
+
entryPoint: "fs",
|
|
107
|
+
targets: [{ format, ...(transparent ? { blend } : {}) }],
|
|
108
|
+
},
|
|
109
|
+
primitive: { topology: "triangle-list", cullMode, frontFace: "ccw" },
|
|
110
|
+
depthStencil: {
|
|
111
|
+
format: "depth24plus",
|
|
112
|
+
depthWriteEnabled: !transparent,
|
|
113
|
+
depthCompare: "less",
|
|
114
|
+
},
|
|
97
115
|
});
|
|
116
|
+
this.pipeline = pipeline("back", false);
|
|
117
|
+
this.transparentBackPipeline = pipeline("front", true);
|
|
118
|
+
this.transparentFrontPipeline = pipeline("back", true);
|
|
98
119
|
this.canvas = canvas;
|
|
99
120
|
this.device = device;
|
|
100
121
|
this.context = context;
|
|
@@ -139,15 +160,15 @@ export class WebGPURenderer {
|
|
|
139
160
|
this.cache.set(mesh, result);
|
|
140
161
|
return result;
|
|
141
162
|
}
|
|
142
|
-
ensureUniformCapacity(
|
|
143
|
-
if (
|
|
163
|
+
ensureUniformCapacity(draws) {
|
|
164
|
+
if (draws <= this.uniformCapacity && this.uniformBuffer)
|
|
144
165
|
return;
|
|
145
166
|
this.uniformBuffer?.destroy?.();
|
|
146
167
|
this.uniformBuffer = this.device.createBuffer({
|
|
147
|
-
size: Math.max(1,
|
|
168
|
+
size: Math.max(1, draws) * UNIFORM_STRIDE,
|
|
148
169
|
usage: globalThis.GPUBufferUsage.UNIFORM | globalThis.GPUBufferUsage.COPY_DST,
|
|
149
170
|
});
|
|
150
|
-
this.uniformCapacity =
|
|
171
|
+
this.uniformCapacity = draws;
|
|
151
172
|
}
|
|
152
173
|
render(frame) {
|
|
153
174
|
if (this.destroyed || !this.device || !this.context || !this.pipeline)
|
|
@@ -155,7 +176,37 @@ export class WebGPURenderer {
|
|
|
155
176
|
if (frame.width === 0 || frame.height === 0 || frame.items.length === 0)
|
|
156
177
|
return;
|
|
157
178
|
this.ensureDepth();
|
|
158
|
-
|
|
179
|
+
const draws = [];
|
|
180
|
+
for (const item of frame.items) {
|
|
181
|
+
if (!item.transparency)
|
|
182
|
+
draws.push({ item, opacity: 1, pipeline: this.pipeline });
|
|
183
|
+
}
|
|
184
|
+
for (const item of frame.items) {
|
|
185
|
+
const transparency = item.transparency;
|
|
186
|
+
if (!transparency)
|
|
187
|
+
continue;
|
|
188
|
+
if (transparency.mode === "two-sided") {
|
|
189
|
+
const resolved = resolveTwoSidedOpacity(transparency);
|
|
190
|
+
draws.push({
|
|
191
|
+
item,
|
|
192
|
+
opacity: resolved.back,
|
|
193
|
+
pipeline: this.transparentBackPipeline,
|
|
194
|
+
});
|
|
195
|
+
draws.push({
|
|
196
|
+
item,
|
|
197
|
+
opacity: resolved.front,
|
|
198
|
+
pipeline: this.transparentFrontPipeline,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
draws.push({
|
|
203
|
+
item,
|
|
204
|
+
opacity: opacity(transparency.opacity, DEFAULT_ONE_SIDED_OPACITY),
|
|
205
|
+
pipeline: this.transparentFrontPipeline,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.ensureUniformCapacity(draws.length);
|
|
159
210
|
const viewProj = multiply(CLIP_Z_FIX, frame.viewProjection);
|
|
160
211
|
const layout = this.pipeline.getBindGroupLayout(0);
|
|
161
212
|
const bindGroup = this.device.createBindGroup({
|
|
@@ -164,12 +215,12 @@ export class WebGPURenderer {
|
|
|
164
215
|
{ binding: 0, resource: { buffer: this.uniformBuffer, offset: 0, size: 160 } },
|
|
165
216
|
],
|
|
166
217
|
});
|
|
167
|
-
|
|
218
|
+
draws.forEach((draw, i) => {
|
|
168
219
|
const data = new Float32Array(UNIFORM_STRIDE / 4);
|
|
169
220
|
data.set(viewProj, 0);
|
|
170
|
-
data.set(item.model, 16);
|
|
221
|
+
data.set(draw.item.model, 16);
|
|
171
222
|
data.set([frame.light.toLight.x, frame.light.toLight.y, frame.light.toLight.z, 0], 32);
|
|
172
|
-
data.set([frame.light.intensity, frame.light.ambient,
|
|
223
|
+
data.set([frame.light.intensity, frame.light.ambient, draw.opacity, 0], 36);
|
|
173
224
|
this.device.queue.writeBuffer(this.uniformBuffer, i * UNIFORM_STRIDE, data);
|
|
174
225
|
});
|
|
175
226
|
const encoder = this.device.createCommandEncoder();
|
|
@@ -189,9 +240,9 @@ export class WebGPURenderer {
|
|
|
189
240
|
depthStoreOp: "store",
|
|
190
241
|
},
|
|
191
242
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
243
|
+
draws.forEach((draw, i) => {
|
|
244
|
+
const mesh = this.buffers(draw.item.mesh);
|
|
245
|
+
pass.setPipeline(draw.pipeline);
|
|
195
246
|
pass.setBindGroup(0, bindGroup, [i * UNIFORM_STRIDE]);
|
|
196
247
|
pass.setVertexBuffer(0, mesh.position);
|
|
197
248
|
pass.setVertexBuffer(1, mesh.normal);
|
|
@@ -215,6 +266,8 @@ export class WebGPURenderer {
|
|
|
215
266
|
this.device = undefined;
|
|
216
267
|
this.context = undefined;
|
|
217
268
|
this.pipeline = undefined;
|
|
269
|
+
this.transparentBackPipeline = undefined;
|
|
270
|
+
this.transparentFrontPipeline = undefined;
|
|
218
271
|
this.uniformBuffer = undefined;
|
|
219
272
|
this.depthTexture = undefined;
|
|
220
273
|
this.canvas = undefined;
|