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 CHANGED
@@ -1,4 +1,4 @@
1
- # 3d-spinner
1
+ # 3d-spinner and beyond
2
2
 
3
3
  [![tests](https://img.shields.io/github/actions/workflow/status/runelaang/3d-spinner/ci.yml?label=tests&logo=github)](https://github.com/runelaang/3d-spinner/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/3d-spinner?logo=npm)](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);
@@ -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?: Partial<Transform>): MeshHandle;
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 = { mesh, transform: makeTransform(init) };
79
- this.scene.push(entry);
80
- return {
78
+ const entry = {
81
79
  mesh,
82
- transform: entry.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: this.camera.options.position,
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 { Renderer, RenderFrame, RendererOptions } from "../renderer.js";
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
- if (dot(normal, subtract(frame.eye, a)) <= 0)
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({ points, color: shadeColor(normal, face.color, frame.light), depth });
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 { Renderer, RenderFrame, RendererOptions } from "../renderer.js";
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, 1.0);
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 { Renderer, RenderFrame, RendererOptions } from "../renderer.js";
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, 1.0);
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
- this.pipeline = device.createRenderPipeline({
88
- layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
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: { module, entryPoint: "fs", targets: [{ format }] },
95
- primitive: { topology: "triangle-list", cullMode: "back", frontFace: "ccw" },
96
- depthStencil: { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" },
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(items) {
143
- if (items <= this.uniformCapacity && this.uniformBuffer)
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, items) * UNIFORM_STRIDE,
168
+ size: Math.max(1, draws) * UNIFORM_STRIDE,
148
169
  usage: globalThis.GPUBufferUsage.UNIFORM | globalThis.GPUBufferUsage.COPY_DST,
149
170
  });
150
- this.uniformCapacity = items;
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
- this.ensureUniformCapacity(frame.items.length);
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
- frame.items.forEach((item, i) => {
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, 0, 0], 36);
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
- pass.setPipeline(this.pipeline);
193
- frame.items.forEach((item, i) => {
194
- const mesh = this.buffers(item.mesh);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3d-spinner",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A lightweight, zero-dependency 3D spinner, loader, and progress indicator for JavaScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",