3d-spinner 0.9.3 → 0.9.5

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.
Files changed (62) hide show
  1. package/dist/animations/charged-orb.js +4 -2
  2. package/dist/animations/ghost-train.d.ts +72 -0
  3. package/dist/animations/ghost-train.js +249 -0
  4. package/dist/animations/grid-assembly.d.ts +11 -8
  5. package/dist/animations/grid-assembly.js +44 -30
  6. package/dist/animations/rocket-launch.d.ts +58 -0
  7. package/dist/animations/rocket-launch.js +375 -0
  8. package/dist/animations/spin.d.ts +3 -1
  9. package/dist/animations/spin.js +6 -1
  10. package/dist/cjs/animations/charged-orb.cjs +170 -28
  11. package/dist/cjs/animations/grid-assembly.cjs +192 -172
  12. package/dist/cjs/animations/object-motion.cjs +156 -24
  13. package/dist/cjs/animations/particles.cjs +167 -26
  14. package/dist/cjs/animations/spin.cjs +169 -27
  15. package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +215 -55
  16. package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +51 -13
  17. package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +56 -6
  18. package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +55 -6
  19. package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +64 -11
  20. package/dist/cjs/prefabs/prefabs.cjs +817 -205
  21. package/dist/engines/little-3d-engine/core/geometry.d.ts +15 -2
  22. package/dist/engines/little-3d-engine/core/geometry.js +25 -3
  23. package/dist/engines/little-3d-engine/core/light.d.ts +28 -4
  24. package/dist/engines/little-3d-engine/core/light.js +48 -7
  25. package/dist/engines/little-3d-engine/core/mesh.d.ts +33 -0
  26. package/dist/engines/little-3d-engine/core/mesh.js +12 -0
  27. package/dist/engines/little-3d-engine/little-3d-engine.d.ts +2 -2
  28. package/dist/engines/little-3d-engine/little-3d-engine.js +1 -1
  29. package/dist/engines/little-3d-engine/loaders/obj.d.ts +8 -7
  30. package/dist/engines/little-3d-engine/loaders/obj.js +74 -20
  31. package/dist/engines/little-3d-engine/renderers/canvas2d.js +23 -1
  32. package/dist/engines/little-3d-engine/renderers/webgl.js +34 -5
  33. package/dist/engines/little-3d-engine/renderers/webgpu.js +43 -10
  34. package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +8 -3
  35. package/dist/engines/little-3d-engine/shapes/complex/plane.js +10 -4
  36. package/dist/engines/little-3d-engine/shapes/primitives/cube.d.ts +3 -2
  37. package/dist/engines/little-3d-engine/shapes/primitives/cube.js +4 -2
  38. package/dist/engines/little-3d-engine/shapes/primitives/octahedron.d.ts +3 -2
  39. package/dist/engines/little-3d-engine/shapes/primitives/octahedron.js +4 -2
  40. package/dist/engines/little-3d-engine/shapes/primitives/pyramid.d.ts +3 -2
  41. package/dist/engines/little-3d-engine/shapes/primitives/pyramid.js +4 -2
  42. package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +3 -2
  43. package/dist/engines/little-3d-engine/shapes/primitives/quad.js +4 -2
  44. package/dist/engines/little-3d-engine/shapes/primitives/spheres/cube-sphere.d.ts +3 -2
  45. package/dist/engines/little-3d-engine/shapes/primitives/spheres/cube-sphere.js +4 -2
  46. package/dist/engines/little-3d-engine/shapes/primitives/spheres/icosphere.d.ts +3 -2
  47. package/dist/engines/little-3d-engine/shapes/primitives/spheres/icosphere.js +4 -2
  48. package/dist/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.d.ts +3 -2
  49. package/dist/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.js +4 -2
  50. package/dist/engines/little-3d-engine/shapes/primitives/spheres/uv-sphere.d.ts +3 -2
  51. package/dist/engines/little-3d-engine/shapes/primitives/spheres/uv-sphere.js +4 -2
  52. package/dist/engines/little-3d-engine/shapes/primitives/tetrahedron.d.ts +3 -2
  53. package/dist/engines/little-3d-engine/shapes/primitives/tetrahedron.js +4 -2
  54. package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -2
  55. package/dist/engines/little-3d-engine/textures/dynamic/star.js +26 -11
  56. package/dist/prefabs/ghost-train.d.ts +5 -4
  57. package/dist/prefabs/ghost-train.js +17 -24
  58. package/dist/prefabs/rocket-launch.d.ts +7 -5
  59. package/dist/prefabs/rocket-launch.js +10 -53
  60. package/dist/umd/spinner.global.js +877 -167
  61. package/dist/umd/spinner.global.min.js +58 -17
  62. package/package.json +2 -2
@@ -7,13 +7,26 @@ export interface TriangleData {
7
7
  positions: Float32Array;
8
8
  normals: Float32Array;
9
9
  colors: Float32Array;
10
+ /**
11
+ * Per-vertex emissive (`Ke`) as linear `0..1` RGB, duplicated across each
12
+ * face's vertices. Faces with no material emit `(0,0,0)`, so a GPU shader can
13
+ * add this term unconditionally and unlit meshes stay identical.
14
+ */
15
+ emissives: Float32Array;
16
+ /**
17
+ * Per-vertex specular packed as 4 floats: `Ks` linear RGB in `xyz` and the
18
+ * `Ns` shininess exponent in `w`. Faces with no specular emit `(0,0,0,1)`, so
19
+ * the highlight term multiplies to zero and unlit meshes stay identical.
20
+ * Note the stride is 4 floats here, not 3 like the other arrays.
21
+ */
22
+ speculars: Float32Array;
10
23
  /** Number of vertices (positions.length / 3). */
11
24
  count: number;
12
25
  }
13
26
  /**
14
27
  * Triangulate a mesh into a non-indexed soup where every vertex carries its
15
- * face's normal and color. This is what GPU backends upload to render flat
16
- * shading without per-primitive state.
28
+ * face's normal, color, and emissive. This is what GPU backends upload to
29
+ * render flat shading without per-primitive state.
17
30
  */
18
31
  export declare function expandToTriangles(mesh: Mesh): TriangleData;
19
32
  /**
@@ -13,8 +13,8 @@ export function parseColor(color) {
13
13
  }
14
14
  /**
15
15
  * Triangulate a mesh into a non-indexed soup where every vertex carries its
16
- * face's normal and color. This is what GPU backends upload to render flat
17
- * shading without per-primitive state.
16
+ * face's normal, color, and emissive. This is what GPU backends upload to
17
+ * render flat shading without per-primitive state.
18
18
  */
19
19
  export function expandToTriangles(mesh) {
20
20
  let triangles = 0;
@@ -23,7 +23,10 @@ export function expandToTriangles(mesh) {
23
23
  const positions = new Float32Array(triangles * 9);
24
24
  const normals = new Float32Array(triangles * 9);
25
25
  const colors = new Float32Array(triangles * 9);
26
+ const emissives = new Float32Array(triangles * 9);
27
+ const speculars = new Float32Array(triangles * 12);
26
28
  let o = 0;
29
+ let so = 0;
27
30
  for (const face of mesh.faces) {
28
31
  const v0 = mesh.vertices[face.indices[0]];
29
32
  const v1 = mesh.vertices[face.indices[1]];
@@ -33,6 +36,17 @@ export function expandToTriangles(mesh) {
33
36
  const cr = r / 255;
34
37
  const cg = g / 255;
35
38
  const cb = b / 255;
39
+ const emissive = face.material?.emissive;
40
+ const er = emissive ? emissive[0] : 0;
41
+ const eg = emissive ? emissive[1] : 0;
42
+ const eb = emissive ? emissive[2] : 0;
43
+ const specular = face.material?.specular;
44
+ const sr = specular ? specular[0] : 0;
45
+ const sg = specular ? specular[1] : 0;
46
+ const sb = specular ? specular[2] : 0;
47
+ // Ns only matters where Ks is non-zero; default the exponent to 1 (32 is
48
+ // the flat-shading default when a specular is present but Ns is omitted).
49
+ const sn = specular ? face.material?.shininess ?? 32 : 1;
36
50
  for (let k = 1; k < face.indices.length - 1; k++) {
37
51
  const tri = [face.indices[0], face.indices[k], face.indices[k + 1]];
38
52
  for (const index of tri) {
@@ -46,11 +60,19 @@ export function expandToTriangles(mesh) {
46
60
  colors[o] = cr;
47
61
  colors[o + 1] = cg;
48
62
  colors[o + 2] = cb;
63
+ emissives[o] = er;
64
+ emissives[o + 1] = eg;
65
+ emissives[o + 2] = eb;
66
+ speculars[so] = sr;
67
+ speculars[so + 1] = sg;
68
+ speculars[so + 2] = sb;
69
+ speculars[so + 3] = sn;
49
70
  o += 3;
71
+ so += 4;
50
72
  }
51
73
  }
52
74
  }
53
- return { positions, normals, colors, count: positions.length / 3 };
75
+ return { positions, normals, colors, emissives, speculars, count: positions.length / 3 };
54
76
  }
55
77
  function midpoint(a, b) {
56
78
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 };
@@ -1,4 +1,5 @@
1
1
  import { type Vec3 } from "./math.js";
2
+ import type { Material } from "./mesh.js";
2
3
  /** Directional light configuration. */
3
4
  export interface LightOptions {
4
5
  /** Direction the light travels. Default points down-forward into the scene. */
@@ -16,10 +17,33 @@ export interface LightParams {
16
17
  ambient: number;
17
18
  }
18
19
  /**
19
- * Flat-shade a face: brighten its base `color` by how directly its `normal`
20
- * faces the light, floored at the ambient level. Returns an `rgb(...)` string.
20
+ * Per-face surface inputs for {@link shade}/{@link shadeColor} beyond the base
21
+ * color: its MTL {@link Material} and the direction from the face toward the
22
+ * camera. Both are needed for the specular highlight; without them shading
23
+ * falls back to flat Lambert plus any emissive term.
21
24
  */
22
- export declare function shadeColor(normal: Vec3, color: string, light: LightParams): string;
25
+ export interface Surface {
26
+ /** MTL-derived specular/shininess/emissive for this face. */
27
+ material?: Material;
28
+ /** Unit vector from the face toward the eye, for the specular highlight. */
29
+ viewDir?: Vec3;
30
+ }
31
+ /**
32
+ * Shade a face and return `0..255` RGB channels.
33
+ *
34
+ * The diffuse term is flat Lambert: the base `color` brightened by how directly
35
+ * `normal` faces the light, floored at the ambient level. When a {@link Surface}
36
+ * supplies a specular material and a `viewDir`, a Blinn-Phong highlight (`Ks`
37
+ * tinted, tightened by `Ns`) is added; an emissive material (`Ke`) is always
38
+ * added on top. With no material this reduces exactly to the previous flat
39
+ * shading.
40
+ */
41
+ export declare function shade(normal: Vec3, color: string, light: LightParams, surface?: Surface): [number, number, number];
42
+ /**
43
+ * Shade a face and return an `rgb(...)` string. Thin wrapper over {@link shade};
44
+ * see it for the shading model. Passing no `surface` gives flat Lambert shading.
45
+ */
46
+ export declare function shadeColor(normal: Vec3, color: string, light: LightParams, surface?: Surface): string;
23
47
  /** A single directional light with an ambient term, used for flat shading. */
24
48
  export declare class Light {
25
49
  readonly options: LightOptions;
@@ -27,5 +51,5 @@ export declare class Light {
27
51
  readonly params: LightParams;
28
52
  constructor(options?: Partial<LightOptions>);
29
53
  /** Convenience wrapper around {@link shadeColor} using this light. */
30
- shade(normal: Vec3, color: string): string;
54
+ shade(normal: Vec3, color: string, surface?: Surface): string;
31
55
  }
@@ -8,15 +8,56 @@ const DEFAULTS = {
8
8
  function clamp01(value) {
9
9
  return Math.min(1, Math.max(0, value));
10
10
  }
11
+ function clamp255(value) {
12
+ return Math.round(Math.min(255, Math.max(0, value)));
13
+ }
11
14
  /**
12
- * Flat-shade a face: brighten its base `color` by how directly its `normal`
13
- * faces the light, floored at the ambient level. Returns an `rgb(...)` string.
15
+ * Shade a face and return `0..255` RGB channels.
16
+ *
17
+ * The diffuse term is flat Lambert: the base `color` brightened by how directly
18
+ * `normal` faces the light, floored at the ambient level. When a {@link Surface}
19
+ * supplies a specular material and a `viewDir`, a Blinn-Phong highlight (`Ks`
20
+ * tinted, tightened by `Ns`) is added; an emissive material (`Ke`) is always
21
+ * added on top. With no material this reduces exactly to the previous flat
22
+ * shading.
14
23
  */
15
- export function shadeColor(normal, color, light) {
24
+ export function shade(normal, color, light, surface) {
16
25
  const lambert = Math.max(0, dot(normal, light.toLight));
17
26
  const brightness = clamp01(light.ambient + light.intensity * lambert);
18
- const [r, g, b] = parseColor(color);
19
- return `rgb(${Math.round(r * brightness)}, ${Math.round(g * brightness)}, ${Math.round(b * brightness)})`;
27
+ const [baseR, baseG, baseB] = parseColor(color);
28
+ let r = baseR * brightness;
29
+ let g = baseG * brightness;
30
+ let b = baseB * brightness;
31
+ const material = surface?.material;
32
+ const specular = material?.specular;
33
+ const viewDir = surface?.viewDir;
34
+ if (specular && viewDir && lambert > 0) {
35
+ const half = normalize({
36
+ x: light.toLight.x + viewDir.x,
37
+ y: light.toLight.y + viewDir.y,
38
+ z: light.toLight.z + viewDir.z,
39
+ });
40
+ const shininess = material?.shininess ?? 32;
41
+ const highlight = Math.pow(Math.max(0, dot(normal, half)), shininess) * light.intensity * 255;
42
+ r += highlight * specular[0];
43
+ g += highlight * specular[1];
44
+ b += highlight * specular[2];
45
+ }
46
+ const emissive = material?.emissive;
47
+ if (emissive) {
48
+ r += emissive[0] * 255;
49
+ g += emissive[1] * 255;
50
+ b += emissive[2] * 255;
51
+ }
52
+ return [clamp255(r), clamp255(g), clamp255(b)];
53
+ }
54
+ /**
55
+ * Shade a face and return an `rgb(...)` string. Thin wrapper over {@link shade};
56
+ * see it for the shading model. Passing no `surface` gives flat Lambert shading.
57
+ */
58
+ export function shadeColor(normal, color, light, surface) {
59
+ const [r, g, b] = shade(normal, color, light, surface);
60
+ return `rgb(${r}, ${g}, ${b})`;
20
61
  }
21
62
  /** A single directional light with an ambient term, used for flat shading. */
22
63
  export class Light {
@@ -29,7 +70,7 @@ export class Light {
29
70
  };
30
71
  }
31
72
  /** Convenience wrapper around {@link shadeColor} using this light. */
32
- shade(normal, color) {
33
- return shadeColor(normal, color, this.params);
73
+ shade(normal, color, surface) {
74
+ return shadeColor(normal, color, this.params, surface);
34
75
  }
35
76
  }
@@ -1,16 +1,49 @@
1
1
  import type { Vec3 } from "./math.js";
2
+ /**
3
+ * Surface response of a face, derived from Wavefront MTL properties. All fields
4
+ * are optional; a face with no material is flat-shaded from its `color` alone,
5
+ * exactly as before materials existed.
6
+ */
7
+ export interface Material {
8
+ /**
9
+ * Specular reflectivity (`Ks`) as linear `0..1` RGB. Drives the color and
10
+ * strength of the highlight. Omit or `[0,0,0]` for a matte surface.
11
+ */
12
+ specular?: [number, number, number];
13
+ /**
14
+ * Specular exponent (`Ns`, Wavefront range `0..1000`). Higher is a tighter,
15
+ * glossier highlight; lower is broad and soft. Ignored without `specular`.
16
+ */
17
+ shininess?: number;
18
+ /**
19
+ * Emissive color (`Ke`) as linear `0..1` RGB, added on top of shading so the
20
+ * face appears self-lit. Omit or `[0,0,0]` for no self-illumination.
21
+ */
22
+ emissive?: [number, number, number];
23
+ }
2
24
  /** A single flat polygon: indices into the mesh `vertices` plus a base color. */
3
25
  export interface Face {
4
26
  /** Vertex indices, wound counter-clockwise when viewed from outside. */
5
27
  indices: number[];
6
28
  /** Base CSS color, for example `"#3b82f6"`. Shading is applied on top of it. */
7
29
  color: string;
30
+ /**
31
+ * Optional surface material (specular, shininess, emissive) from an MTL
32
+ * file. When absent the face is flat Lambert-shaded from `color`.
33
+ */
34
+ material?: Material;
8
35
  }
9
36
  /** Geometry: a list of vertices and the colored faces that connect them. */
10
37
  export interface Mesh {
11
38
  vertices: Vec3[];
12
39
  faces: Face[];
13
40
  }
41
+ /**
42
+ * Assign one {@link Material} to every face of a mesh, in place, and return it.
43
+ * A no-op when `material` is omitted. Shape builders use this to apply a uniform
44
+ * surface material (specular, shininess, emissive) across all their faces.
45
+ */
46
+ export declare function attachMaterial(mesh: Mesh, material?: Material): Mesh;
14
47
  /** Draw only outward-facing transparent surfaces. */
15
48
  export interface OneSidedTransparency {
16
49
  mode: "one-sided";
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Assign one {@link Material} to every face of a mesh, in place, and return it.
3
+ * A no-op when `material` is omitted. Shape builders use this to apply a uniform
4
+ * surface material (specular, shininess, emissive) across all their faces.
5
+ */
6
+ export function attachMaterial(mesh, material) {
7
+ if (material) {
8
+ for (const face of mesh.faces)
9
+ face.material = material;
10
+ }
11
+ return mesh;
12
+ }
1
13
  /** Create a {@link Transform} with sensible defaults (origin, no rotation). */
2
14
  export function transform(init) {
3
15
  return {
@@ -80,8 +80,8 @@ export { starTexture } from "./textures/dynamic/star.js";
80
80
  export { shineTexture } from "./textures/dynamic/shine.js";
81
81
  export { streakTexture } from "./textures/dynamic/streak.js";
82
82
  export { expandToTriangles } from "./core/geometry.js";
83
- export type { Mesh, Face, Transform, Transparency, OneSidedTransparency, TwoSidedTransparency, } from "./core/mesh.js";
84
- export { transform } from "./core/mesh.js";
83
+ export type { Mesh, Face, Material, Transform, Transparency, OneSidedTransparency, TwoSidedTransparency, } from "./core/mesh.js";
84
+ export { transform, attachMaterial } from "./core/mesh.js";
85
85
  export type { Backend, Renderer, RendererFactory, RenderFrame, RenderItem, RendererOptions, } from "./renderer.js";
86
86
  export { orderRenderItems } from "./renderer.js";
87
87
  export { type Vec3, vec3, subtract, cross, dot, scale, normalize, } from "./core/math.js";
@@ -171,6 +171,6 @@ export { starTexture } from "./textures/dynamic/star.js";
171
171
  export { shineTexture } from "./textures/dynamic/shine.js";
172
172
  export { streakTexture } from "./textures/dynamic/streak.js";
173
173
  export { expandToTriangles } from "./core/geometry.js";
174
- export { transform } from "./core/mesh.js";
174
+ export { transform, attachMaterial } from "./core/mesh.js";
175
175
  export { orderRenderItems } from "./renderer.js";
176
176
  export { vec3, subtract, cross, dot, scale, normalize, } from "./core/math.js";
@@ -10,8 +10,9 @@ export interface ObjOptions {
10
10
  /** Contents of an `.mtl` file referenced by the OBJ. */
11
11
  mtl?: string;
12
12
  /**
13
- * Use MTL diffuse (`Kd`) values for faces with matching `usemtl`
14
- * statements. Default `false`.
13
+ * Use MTL material values for faces with matching `usemtl` statements: the
14
+ * diffuse color (`Kd`) becomes the face color, and specular (`Ks`/`Ns`) and
15
+ * emissive (`Ke`) become the face {@link Material}. Default `false`.
15
16
  */
16
17
  useMtlColors?: boolean;
17
18
  }
@@ -21,12 +22,12 @@ export interface ObjOptions {
21
22
  * Reads `v` vertex positions and `f` faces (triangles, quads, or n-gons, in
22
23
  * `v`, `v/vt`, `v/vt/vn`, or `v//vn` form, with 1-based or negative indices).
23
24
  * Normals (`vn`) and texture coordinates (`vt`) are ignored - the engine
24
- * computes a flat normal per face. Material names can select diffuse colors
25
- * from supplied MTL text; groups and other statements are ignored. Face
26
- * winding is preserved as-is; the engine expects CCW winding as seen from
27
- * outside.
25
+ * computes a flat normal per face. Material names can select the diffuse color
26
+ * (`Kd`) and surface material (specular `Ks`/`Ns`, emissive `Ke`) from supplied
27
+ * MTL text; groups and other statements are ignored. Face winding is preserved
28
+ * as-is; the engine expects CCW winding as seen from outside.
28
29
  *
29
30
  * @param text Contents of an `.obj` file.
30
- * @param options Face palette and optional MTL diffuse colors.
31
+ * @param options Face palette and optional MTL materials.
31
32
  */
32
33
  export declare function parseObj(text: string, options?: ObjOptions): Mesh;
@@ -1,31 +1,82 @@
1
1
  const DEFAULT_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444"];
2
+ function clamp01(value) {
3
+ return Math.min(1, Math.max(0, value));
4
+ }
2
5
  function channelToHex(value) {
3
6
  const channel = Number.parseFloat(value);
4
7
  if (!Number.isFinite(channel))
5
8
  return undefined;
6
- return Math.round(Math.min(1, Math.max(0, channel)) * 255)
9
+ return Math.round(clamp01(channel) * 255)
7
10
  .toString(16)
8
11
  .padStart(2, "0");
9
12
  }
10
- function parseMtlColors(text) {
11
- const colors = new Map();
12
- let material;
13
+ /** Parse an `Ks`/`Ke`-style RGB triple into clamped `0..1` channels. */
14
+ function parseRgb(parts) {
15
+ const channels = parts.slice(1, 4).map(Number.parseFloat);
16
+ if (channels.length !== 3 || !channels.every(Number.isFinite))
17
+ return undefined;
18
+ return [clamp01(channels[0]), clamp01(channels[1]), clamp01(channels[2])];
19
+ }
20
+ /** Fold specular/shininess/emissive into a {@link Material}, or `undefined`. */
21
+ function toMaterial(surface) {
22
+ const material = {};
23
+ if (surface.specular)
24
+ material.specular = surface.specular;
25
+ if (surface.shininess !== undefined)
26
+ material.shininess = surface.shininess;
27
+ if (surface.emissive)
28
+ material.emissive = surface.emissive;
29
+ return Object.keys(material).length > 0 ? material : undefined;
30
+ }
31
+ /**
32
+ * Parse MTL text into a map of material name to its color and surface material.
33
+ * Reads `Kd` (diffuse color), `Ks` (specular), `Ns` (shininess), and `Ke`
34
+ * (emissive); other statements are ignored.
35
+ */
36
+ function parseMtl(text) {
37
+ const materials = new Map();
38
+ const surfaces = new Map();
39
+ let name;
13
40
  for (const line of text.split("\n")) {
14
41
  const trimmed = line.trim();
15
42
  if (trimmed === "" || trimmed.startsWith("#"))
16
43
  continue;
17
44
  const parts = trimmed.split(/\s+/);
18
- if (parts[0] === "newmtl") {
19
- material = parts.slice(1).join(" ");
45
+ const keyword = parts[0];
46
+ if (keyword === "newmtl") {
47
+ name = parts.slice(1).join(" ");
48
+ if (name && !materials.has(name)) {
49
+ materials.set(name, {});
50
+ surfaces.set(name, {});
51
+ }
52
+ continue;
20
53
  }
21
- else if (parts[0] === "Kd" && material) {
54
+ if (!name)
55
+ continue;
56
+ const entry = materials.get(name);
57
+ const surface = surfaces.get(name);
58
+ if (keyword === "Kd") {
22
59
  const channels = parts.slice(1, 4).map(channelToHex);
23
60
  if (channels.length === 3 && channels.every((channel) => channel !== undefined)) {
24
- colors.set(material, `#${channels.join("")}`);
61
+ entry.color = `#${channels.join("")}`;
25
62
  }
26
63
  }
64
+ else if (keyword === "Ks") {
65
+ surface.specular = parseRgb(parts);
66
+ }
67
+ else if (keyword === "Ns") {
68
+ const ns = Number.parseFloat(parts[1]);
69
+ if (Number.isFinite(ns))
70
+ surface.shininess = Math.max(0, ns);
71
+ }
72
+ else if (keyword === "Ke") {
73
+ surface.emissive = parseRgb(parts);
74
+ }
75
+ }
76
+ for (const [key, surface] of surfaces) {
77
+ materials.get(key).material = toMaterial(surface);
27
78
  }
28
- return colors;
79
+ return materials;
29
80
  }
30
81
  function resolveIndex(token, vertexCount) {
31
82
  const n = parseInt(token, 10);
@@ -37,18 +88,18 @@ function resolveIndex(token, vertexCount) {
37
88
  * Reads `v` vertex positions and `f` faces (triangles, quads, or n-gons, in
38
89
  * `v`, `v/vt`, `v/vt/vn`, or `v//vn` form, with 1-based or negative indices).
39
90
  * Normals (`vn`) and texture coordinates (`vt`) are ignored - the engine
40
- * computes a flat normal per face. Material names can select diffuse colors
41
- * from supplied MTL text; groups and other statements are ignored. Face
42
- * winding is preserved as-is; the engine expects CCW winding as seen from
43
- * outside.
91
+ * computes a flat normal per face. Material names can select the diffuse color
92
+ * (`Kd`) and surface material (specular `Ks`/`Ns`, emissive `Ke`) from supplied
93
+ * MTL text; groups and other statements are ignored. Face winding is preserved
94
+ * as-is; the engine expects CCW winding as seen from outside.
44
95
  *
45
96
  * @param text Contents of an `.obj` file.
46
- * @param options Face palette and optional MTL diffuse colors.
97
+ * @param options Face palette and optional MTL materials.
47
98
  */
48
99
  export function parseObj(text, options = {}) {
49
100
  const colors = options.colors ?? DEFAULT_COLORS;
50
- const materialColors = options.useMtlColors && options.mtl
51
- ? parseMtlColors(options.mtl)
101
+ const materials = options.useMtlColors && options.mtl
102
+ ? parseMtl(options.mtl)
52
103
  : undefined;
53
104
  const vertices = [];
54
105
  const faces = [];
@@ -76,10 +127,13 @@ export function parseObj(text, options = {}) {
76
127
  indices.push(resolveIndex(vertexToken, vertices.length));
77
128
  }
78
129
  if (indices.length >= 3) {
79
- const mtlColor = material ? materialColors?.get(material) : undefined;
80
- const color = mtlColor
81
- ?? (materialColors ? (colors[0] ?? "#888888") : colors[faces.length % colors.length]);
82
- faces.push({ indices, color });
130
+ const entry = material ? materials?.get(material) : undefined;
131
+ const color = entry?.color
132
+ ?? (materials ? (colors[0] ?? "#888888") : colors[faces.length % colors.length]);
133
+ const face = { indices, color };
134
+ if (entry?.material)
135
+ face.material = entry.material;
136
+ faces.push(face);
83
137
  }
84
138
  }
85
139
  }
@@ -60,9 +60,31 @@ export class Canvas2DRenderer {
60
60
  depth += dot(d, d);
61
61
  }
62
62
  depth /= face.indices.length;
63
+ // Specular needs the view direction from the face toward the eye; use
64
+ // the face centroid. Emissive-only or plain faces skip that work.
65
+ let surface;
66
+ const material = face.material;
67
+ if (material) {
68
+ if (material.specular) {
69
+ let cx = 0;
70
+ let cy = 0;
71
+ let cz = 0;
72
+ for (const i of face.indices) {
73
+ cx += world[i].x;
74
+ cy += world[i].y;
75
+ cz += world[i].z;
76
+ }
77
+ const inv = 1 / face.indices.length;
78
+ const viewDir = normalize(subtract(frame.eye, { x: cx * inv, y: cy * inv, z: cz * inv }));
79
+ surface = { material, viewDir };
80
+ }
81
+ else {
82
+ surface = { material };
83
+ }
84
+ }
63
85
  polygons.push({
64
86
  points,
65
- color: shadeColor(normal, face.color, frame.light),
87
+ color: shadeColor(normal, face.color, frame.light, surface),
66
88
  depth,
67
89
  opacity: faceOpacity,
68
90
  });
@@ -4,28 +4,51 @@ const VERTEX_SHADER = `#version 300 es
4
4
  in vec3 aPos;
5
5
  in vec3 aNormal;
6
6
  in vec3 aColor;
7
+ in vec3 aEmissive;
8
+ in vec4 aSpecular;
7
9
  uniform mat4 uViewProj;
8
10
  uniform mat4 uModel;
9
11
  out vec3 vNormal;
10
12
  out vec3 vColor;
13
+ out vec3 vEmissive;
14
+ out vec4 vSpecular;
15
+ out vec3 vWorldPos;
11
16
  void main() {
12
17
  vNormal = mat3(uModel) * aNormal;
13
18
  vColor = aColor;
14
- gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
19
+ vEmissive = aEmissive;
20
+ vSpecular = aSpecular;
21
+ vec4 world = uModel * vec4(aPos, 1.0);
22
+ vWorldPos = world.xyz;
23
+ gl_Position = uViewProj * world;
15
24
  }`;
16
25
  const FRAGMENT_SHADER = `#version 300 es
17
26
  precision mediump float;
18
27
  in vec3 vNormal;
19
28
  in vec3 vColor;
29
+ in vec3 vEmissive;
30
+ in vec4 vSpecular;
31
+ in vec3 vWorldPos;
20
32
  uniform vec3 uToLight;
33
+ uniform vec3 uEye;
21
34
  uniform float uIntensity;
22
35
  uniform float uAmbient;
23
36
  uniform float uOpacity;
24
37
  out vec4 fragColor;
25
38
  void main() {
26
- float lambert = max(dot(normalize(vNormal), normalize(uToLight)), 0.0);
39
+ vec3 normal = normalize(vNormal);
40
+ vec3 toLight = normalize(uToLight);
41
+ float lambert = max(dot(normal, toLight), 0.0);
27
42
  float brightness = clamp(uAmbient + uIntensity * lambert, 0.0, 1.0);
28
- fragColor = vec4(vColor * brightness, uOpacity);
43
+ vec3 lit = vColor * brightness;
44
+ if (lambert > 0.0) {
45
+ vec3 viewDir = normalize(uEye - vWorldPos);
46
+ vec3 halfVec = normalize(toLight + viewDir);
47
+ float highlight = pow(max(dot(normal, halfVec), 0.0), vSpecular.w) * uIntensity;
48
+ lit += highlight * vSpecular.xyz;
49
+ }
50
+ lit += vEmissive;
51
+ fragColor = vec4(lit, uOpacity);
29
52
  }`;
30
53
  function compile(gl, type, source) {
31
54
  const shader = gl.createShader(type);
@@ -68,9 +91,12 @@ export class WebGLRenderer {
68
91
  aPos: gl.getAttribLocation(this.program, "aPos"),
69
92
  aNormal: gl.getAttribLocation(this.program, "aNormal"),
70
93
  aColor: gl.getAttribLocation(this.program, "aColor"),
94
+ aEmissive: gl.getAttribLocation(this.program, "aEmissive"),
95
+ aSpecular: gl.getAttribLocation(this.program, "aSpecular"),
71
96
  uViewProj: gl.getUniformLocation(this.program, "uViewProj"),
72
97
  uModel: gl.getUniformLocation(this.program, "uModel"),
73
98
  uToLight: gl.getUniformLocation(this.program, "uToLight"),
99
+ uEye: gl.getUniformLocation(this.program, "uEye"),
74
100
  uIntensity: gl.getUniformLocation(this.program, "uIntensity"),
75
101
  uAmbient: gl.getUniformLocation(this.program, "uAmbient"),
76
102
  uOpacity: gl.getUniformLocation(this.program, "uOpacity"),
@@ -96,18 +122,20 @@ export class WebGLRenderer {
96
122
  const data = expandToTriangles(mesh);
97
123
  const vao = gl.createVertexArray();
98
124
  gl.bindVertexArray(vao);
99
- const attribute = (location, array) => {
125
+ const attribute = (location, array, size = 3) => {
100
126
  if (location < 0)
101
127
  return;
102
128
  const buffer = gl.createBuffer();
103
129
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
104
130
  gl.bufferData(gl.ARRAY_BUFFER, array, gl.STATIC_DRAW);
105
131
  gl.enableVertexAttribArray(location);
106
- gl.vertexAttribPointer(location, 3, gl.FLOAT, false, 0, 0);
132
+ gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0);
107
133
  };
108
134
  attribute(loc.aPos, data.positions);
109
135
  attribute(loc.aNormal, data.normals);
110
136
  attribute(loc.aColor, data.colors);
137
+ attribute(loc.aEmissive, data.emissives);
138
+ attribute(loc.aSpecular, data.speculars, 4);
111
139
  gl.bindVertexArray(null);
112
140
  const result = { vao, count: data.count };
113
141
  this.cache.set(mesh, result);
@@ -123,6 +151,7 @@ export class WebGLRenderer {
123
151
  gl.useProgram(this.program);
124
152
  gl.uniformMatrix4fv(loc.uViewProj, false, new Float32Array(frame.viewProjection));
125
153
  gl.uniform3f(loc.uToLight, frame.light.toLight.x, frame.light.toLight.y, frame.light.toLight.z);
154
+ gl.uniform3f(loc.uEye, frame.eye.x, frame.eye.y, frame.eye.z);
126
155
  gl.uniform1f(loc.uIntensity, frame.light.intensity);
127
156
  gl.uniform1f(loc.uAmbient, frame.light.ambient);
128
157
  gl.disable(gl.BLEND);