@netforge/asset-library 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Crowd models: a library model baked into static geometry for
3
+ * InstancedMesh, so 2,000 enemies stay one draw per material group. The
4
+ * model is posed once (a frame of its walk or idle clip, so nobody is
5
+ * frozen in a T-pose), skinning is applied on the CPU, and parts are merged:
6
+ * untextured materials become vertex colours in one draw, textured ones
7
+ * keep their map (one draw per map). Motion comes from the body plan
8
+ * (bob, waddle, hover, squash) in the template's instance matrices.
9
+ */
10
+ import { AnimationMixer, BufferAttribute, BufferGeometry, Color, InstancedBufferAttribute, InstancedBufferGeometry, InstancedMesh, Matrix4, Mesh, MeshStandardNodeMaterial, Quaternion, Vector3 } from "three/webgpu";
11
+ import { float, instancedBufferAttribute, positionGeometry } from "three/tsl";
12
+ import { billboardWorld, isSprite } from "@netforge/engine/runtime";
13
+ import type { MeshStandardMaterial } from "three/webgpu";
14
+ import type { AnimationClip, Material, SkinnedMesh, Texture } from "three/webgpu";
15
+ import type { RigDef } from "../rigs.js";
16
+ import { clipFor } from "../rigs.js";
17
+ import type { PreparedModel } from "./prepare.js";
18
+
19
+ export interface CrowdPart {
20
+ readonly geometry: BufferGeometry;
21
+ readonly material: Material;
22
+ }
23
+
24
+ export interface CrowdModel {
25
+ readonly parts: readonly CrowdPart[];
26
+ readonly height: number;
27
+ readonly triangles: number;
28
+ }
29
+
30
+ interface Bucket {
31
+ pos: number[];
32
+ nor: number[];
33
+ uv: number[];
34
+ col: number[];
35
+ map: Texture | null;
36
+ emissive: Color;
37
+ roughness: number;
38
+ metalness: number;
39
+ }
40
+
41
+ export interface Placement {
42
+ readonly x: number;
43
+ readonly z: number;
44
+ readonly y?: number;
45
+ readonly yaw?: number;
46
+ /** Scale across (x and z) and up (y). Default 1. */
47
+ readonly across?: number;
48
+ readonly up?: number;
49
+ }
50
+
51
+ /** Sprite scenery (pixel style): one draw of camera-facing planes, each at its spot and scale (yaw does not apply). */
52
+ function placeSprites(p: CrowdPart, placements: readonly Placement[]): Mesh {
53
+ const g = new InstancedBufferGeometry();
54
+ if (p.geometry.index) g.setIndex(p.geometry.index);
55
+ for (const [name, a] of Object.entries(p.geometry.attributes)) g.setAttribute(name, a);
56
+ const spots = new Float32Array(Math.max(1, placements.length) * 4);
57
+ placements.forEach((pl, i) => spots.set([pl.x, pl.y ?? 0, pl.z, pl.up ?? 1], i * 4));
58
+ const spot = new InstancedBufferAttribute(spots, 4);
59
+ g.instanceCount = placements.length;
60
+ const m = (p.material as MeshStandardNodeMaterial).clone();
61
+ const at = instancedBufferAttribute<"vec4">(spot, "vec4");
62
+ m.positionNode = billboardWorld(at.xyz, positionGeometry.x.mul(at.w), positionGeometry.y.mul(at.w), float(1));
63
+ const mesh = new Mesh(g, m);
64
+ mesh.frustumCulled = false;
65
+ return mesh;
66
+ }
67
+
68
+ /** Static scenery: one InstancedMesh per part (sprites: one billboard draw), every placement drawn. Add the result to the scene. */
69
+ export function placeStatic(model: CrowdModel, placements: readonly Placement[]): Mesh[] {
70
+ const m = new Matrix4();
71
+ const q = new Quaternion();
72
+ const axis = new Vector3(0, 1, 0);
73
+ const pos = new Vector3();
74
+ const scl = new Vector3();
75
+ return model.parts.map((p) => {
76
+ if (isSprite(p.geometry)) return placeSprites(p, placements);
77
+ const mesh = new InstancedMesh(p.geometry, p.material, Math.max(1, placements.length));
78
+ placements.forEach((pl, i) => {
79
+ q.setFromAxisAngle(axis, pl.yaw ?? 0);
80
+ pos.set(pl.x, pl.y ?? 0, pl.z);
81
+ scl.set(pl.across ?? 1, pl.up ?? pl.across ?? 1, pl.across ?? 1);
82
+ mesh.setMatrixAt(i, m.compose(pos, q, scl));
83
+ });
84
+ mesh.count = placements.length;
85
+ mesh.instanceMatrix.needsUpdate = true;
86
+ mesh.computeBoundingSphere();
87
+ return mesh;
88
+ });
89
+ }
90
+
91
+ /** Pose a prepared model at a frame of a clip (walk, else idle), in place. */
92
+ export function poseModel(p: PreparedModel, clips: readonly AnimationClip[], rig: RigDef | undefined, at = 0.25): void {
93
+ if (!rig || clips.length === 0) return;
94
+ const name = clipFor(rig, "walk", clips.map((c) => c.name)) ?? clipFor(rig, "idle", clips.map((c) => c.name));
95
+ const clip = clips.find((c) => c.name === name);
96
+ if (!clip) return;
97
+ const mixer = new AnimationMixer(p.model);
98
+ mixer.clipAction(clip).play();
99
+ mixer.update(Math.min(at, clip.duration * 0.5));
100
+ p.root.updateMatrixWorld(true);
101
+ }
102
+
103
+ /**
104
+ * Skinning and mirrored exports can leave a mesh inside out. Faces of a
105
+ * closed low-poly model mostly point away from its centre: if the summed
106
+ * area-weighted outward dot is negative, swap every triangle's winding.
107
+ */
108
+ function orientOutward(b: Bucket): void {
109
+ const p = b.pos;
110
+ const n = p.length / 9;
111
+ if (n === 0) return;
112
+ let cx = 0;
113
+ let cy = 0;
114
+ let cz = 0;
115
+ for (let i = 0; i < p.length; i += 3) {
116
+ cx += p[i] ?? 0;
117
+ cy += p[i + 1] ?? 0;
118
+ cz += p[i + 2] ?? 0;
119
+ }
120
+ const vn = p.length / 3;
121
+ cx /= vn;
122
+ cy /= vn;
123
+ cz /= vn;
124
+ let score = 0;
125
+ for (let t = 0; t < n; t++) {
126
+ const o = t * 9;
127
+ const ax = p[o] ?? 0, ay = p[o + 1] ?? 0, az = p[o + 2] ?? 0;
128
+ const ux = (p[o + 3] ?? 0) - ax, uy = (p[o + 4] ?? 0) - ay, uz = (p[o + 5] ?? 0) - az;
129
+ const wx = (p[o + 6] ?? 0) - ax, wy = (p[o + 7] ?? 0) - ay, wz = (p[o + 8] ?? 0) - az;
130
+ const nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx;
131
+ score += nx * (ax - cx) + ny * (ay - cy) + nz * (az - cz);
132
+ }
133
+ if (score >= 0) return;
134
+ const swap = (arr: number[], width: number) => {
135
+ for (let t = 0; t < n; t++) {
136
+ const b1 = (t * 3 + 1) * width;
137
+ const b2 = (t * 3 + 2) * width;
138
+ for (let k = 0; k < width; k++) {
139
+ const x = arr[b1 + k] ?? 0;
140
+ arr[b1 + k] = arr[b2 + k] ?? 0;
141
+ arr[b2 + k] = x;
142
+ }
143
+ }
144
+ };
145
+ swap(b.pos, 3);
146
+ swap(b.col, 3);
147
+ swap(b.uv, 2);
148
+ }
149
+
150
+ /** Bake a prepared (and posed) model into merged static parts in the root's frame. */
151
+ export function bakeCrowd(p: PreparedModel): CrowdModel {
152
+ p.root.updateMatrixWorld(true);
153
+ const rootInv = p.root.matrixWorld.clone().invert();
154
+ const buckets = new Map<string, Bucket>();
155
+ const v = new Vector3();
156
+ const tmp = new Color();
157
+ let triangles = 0;
158
+ p.root.traverse((o) => {
159
+ const m = o as Mesh;
160
+ if (!m.isMesh || !m.visible) return;
161
+ const g = m.geometry as BufferGeometry;
162
+ const pos = g.getAttribute("position");
163
+ if (!pos) return;
164
+ const uv = g.getAttribute("uv");
165
+ // Vertex colours (the voxel style's per-voxel colour and occlusion) multiply the material's.
166
+ const vc = g.getAttribute("color");
167
+ const index = g.getIndex();
168
+ const mats: Material[] = Array.isArray(m.material) ? m.material : [m.material];
169
+ const groups = g.groups.length ? g.groups : [{ start: 0, count: index ? index.count : pos.count, materialIndex: 0 }];
170
+ const world = rootInv.clone().multiply(m.matrixWorld);
171
+ const skinned = (m as SkinnedMesh).isSkinnedMesh;
172
+ // A mirrored transform flips triangle winding; swap two corners back so faces point out.
173
+ const mirrored = world.determinant() < 0;
174
+ for (const grp of groups) {
175
+ const mat = (mats[grp.materialIndex ?? 0] ?? mats[0]) as MeshStandardMaterial;
176
+ const map = mat.map ?? null;
177
+ const key = map ? `map:${map.uuid}` : "color";
178
+ let b = buckets.get(key);
179
+ if (!b) {
180
+ b = { pos: [], nor: [], uv: [], col: [], map, emissive: (mat.emissive ?? new Color(0)).clone(), roughness: mat.roughness ?? 0.7, metalness: mat.metalness ?? 0 };
181
+ buckets.set(key, b);
182
+ }
183
+ const color = mat.color ? tmp.copy(mat.color) : tmp.setRGB(1, 1, 1);
184
+ for (let k = grp.start; k < grp.start + grp.count; k++) {
185
+ const corner = (k - grp.start) % 3;
186
+ const kk = mirrored && corner > 0 ? k + (corner === 1 ? 1 : -1) : k;
187
+ const i = index ? index.getX(kk) : kk;
188
+ if (skinned) (m as SkinnedMesh).getVertexPosition(i, v);
189
+ else v.fromBufferAttribute(pos, i);
190
+ v.applyMatrix4(world);
191
+ b.pos.push(v.x, v.y, v.z);
192
+ b.uv.push(uv ? uv.getX(i) : 0, uv ? uv.getY(i) : 0);
193
+ if (vc) b.col.push(color.r * vc.getX(i), color.g * vc.getY(i), color.b * vc.getZ(i));
194
+ else b.col.push(color.r, color.g, color.b);
195
+ }
196
+ triangles += Math.floor(grp.count / 3);
197
+ }
198
+ });
199
+ const parts: CrowdPart[] = [];
200
+ for (const b of buckets.values()) {
201
+ orientOutward(b);
202
+ const geometry = new BufferGeometry();
203
+ geometry.setAttribute("position", new BufferAttribute(new Float32Array(b.pos), 3));
204
+ geometry.setAttribute("color", new BufferAttribute(new Float32Array(b.col), 3));
205
+ if (b.map) geometry.setAttribute("uv", new BufferAttribute(new Float32Array(b.uv), 2));
206
+ // Flat normals: the library is low-poly and reads best faceted.
207
+ geometry.computeVertexNormals();
208
+ geometry.computeBoundingSphere();
209
+ // A node material (vertex colours on) so the engine's AnimatedCrowd can take it as-is, and plain InstancedMesh draws it too.
210
+ const material = new MeshStandardNodeMaterial();
211
+ Object.assign(material, { vertexColors: true, map: b.map, roughness: b.roughness, metalness: b.metalness, flatShading: true });
212
+ material.emissive.copy(b.emissive);
213
+ parts.push({ geometry, material });
214
+ }
215
+ return { parts, height: p.height, triangles };
216
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @netforge/asset-library/three: draw library models with three.js
3
+ * (WebGPU with the WebGL2 fallback, through the engine runtime).
4
+ */
5
+ export { LibraryClient, spriteMultiple } from "./client.js";
6
+ export type { CharacterInstance, LibraryClientOptions, ModelSource } from "./client.js";
7
+ export { RigAnimator, StaticAnimator } from "./animator.js";
8
+ export type { CharacterAnimator } from "./animator.js";
9
+ export { bakeCrowd, placeStatic, poseModel } from "./crowd.js";
10
+ export type { CrowdModel, CrowdPart, Placement } from "./crowd.js";
11
+ export { prepareModel, shiftColor } from "./prepare.js";
12
+ export type { LoadedModel, PreparedModel } from "./prepare.js";
13
+ export { buildPart, proceduralAsset } from "./parts.js";
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Look parts and procedural pickups, built from three.js primitives.
3
+ * Parts are sized from the model's height and placed at an anchor; the
4
+ * caller parents them so they follow the animation.
5
+ */
6
+ import {
7
+ BoxGeometry,
8
+ BufferGeometry,
9
+ Color,
10
+ ConeGeometry,
11
+ CylinderGeometry,
12
+ Group,
13
+ IcosahedronGeometry,
14
+ Mesh,
15
+ MeshStandardMaterial,
16
+ OctahedronGeometry,
17
+ SphereGeometry,
18
+ TorusGeometry,
19
+ } from "three/webgpu";
20
+ import type { AppearancePart } from "@netforge/engine";
21
+ import { voxel } from "@netforge/engine/runtime";
22
+
23
+ const mat = (color: number, emissive = 0, intensity = 1) =>
24
+ new MeshStandardMaterial({ color: new Color(color), emissive: new Color(emissive), emissiveIntensity: intensity, roughness: 0.6, flatShading: true });
25
+
26
+ function mesh(g: BufferGeometry, m: MeshStandardMaterial, x = 0, y = 0, z = 0, rx = 0, ry = 0, rz = 0): Mesh {
27
+ const o = new Mesh(g, m);
28
+ o.position.set(x, y, z);
29
+ o.rotation.set(rx, ry, rz);
30
+ return o;
31
+ }
32
+
33
+ /**
34
+ * One part as a group whose origin is its anchor point, sized for a model
35
+ * `h` metres tall and `w` metres wide, in the model's frame (facing -z,
36
+ * y up). `accent` colours it; `body` is the model's main colour.
37
+ */
38
+ export function buildPart(part: AppearancePart, h: number, w: number, accent: number, body: number): Group {
39
+ const g = new Group();
40
+ g.name = `look:${part}`;
41
+ const a = mat(accent);
42
+ const b = mat(body);
43
+ const s = h * 0.12;
44
+ switch (part) {
45
+ case "horns":
46
+ for (const side of [-1, 1]) g.add(mesh(new ConeGeometry(s * 0.35, s * 1.4, 6), a, side * w * 0.22, s * 0.5, 0, 0, 0, -side * 0.45));
47
+ break;
48
+ case "ears_long":
49
+ for (const side of [-1, 1]) {
50
+ const ear = new SphereGeometry(s * 0.45, 8, 6);
51
+ ear.scale(0.45, 2.4, 0.3);
52
+ g.add(mesh(ear, b, side * w * 0.2, s * 0.9, 0, 0, 0, -side * 0.25));
53
+ const tip = new ConeGeometry(s * 0.22, s * 0.55, 6);
54
+ g.add(mesh(tip, a, side * (w * 0.2 + s * 0.27), s * 1.85, 0, 0, 0, -side * 0.25));
55
+ }
56
+ break;
57
+ case "ears_round":
58
+ for (const side of [-1, 1]) {
59
+ const ear = new SphereGeometry(s * 0.5, 10, 8);
60
+ ear.scale(1, 1, 0.35);
61
+ g.add(mesh(ear, b, side * w * 0.24, s * 0.35, 0));
62
+ }
63
+ break;
64
+ case "antenna":
65
+ g.add(mesh(new CylinderGeometry(s * 0.05, s * 0.05, s * 1.2, 5), a, 0, s * 0.6, 0));
66
+ g.add(mesh(new SphereGeometry(s * 0.2, 8, 6), mat(accent, accent, 1.5), 0, s * 1.25, 0));
67
+ break;
68
+ case "crown": {
69
+ g.add(mesh(new CylinderGeometry(w * 0.2, w * 0.22, s * 0.35, 10, 1, true), mat(0xffd34d, 0x6a4a00, 0.4), 0, s * 0.15, 0));
70
+ for (let i = 0; i < 5; i++) {
71
+ const t = (i / 5) * Math.PI * 2;
72
+ g.add(mesh(new ConeGeometry(s * 0.12, s * 0.35, 4), mat(0xffd34d, 0x6a4a00, 0.4), Math.cos(t) * w * 0.2, s * 0.45, Math.sin(t) * w * 0.2));
73
+ }
74
+ break;
75
+ }
76
+ case "hat_cap": {
77
+ const dome = new SphereGeometry(w * 0.24, 10, 6, 0, Math.PI * 2, 0, Math.PI / 2);
78
+ g.add(mesh(dome, a, 0, -s * 0.1, 0));
79
+ const brim = new CylinderGeometry(w * 0.16, w * 0.16, s * 0.06, 10, 1, false, -Math.PI / 2, Math.PI);
80
+ g.add(mesh(brim, a, 0, -s * 0.08, -w * 0.18, 0, 0, 0));
81
+ break;
82
+ }
83
+ case "hat_top":
84
+ g.add(mesh(new CylinderGeometry(w * 0.3, w * 0.3, s * 0.08, 12), a, 0, 0, 0));
85
+ g.add(mesh(new CylinderGeometry(w * 0.17, w * 0.18, s * 1.1, 12), a, 0, s * 0.55, 0));
86
+ break;
87
+ case "mustache":
88
+ for (const side of [-1, 1]) {
89
+ const m = new SphereGeometry(s * 0.28, 8, 6);
90
+ m.scale(1.6, 0.55, 0.6);
91
+ g.add(mesh(m, a, side * s * 0.32, 0, 0, 0, 0, side * 0.35));
92
+ }
93
+ break;
94
+ case "tail": {
95
+ const t = new ConeGeometry(s * 0.3, s * 2, 7);
96
+ g.add(mesh(t, b, 0, s * 0.3, s * 0.8, -1.1, 0, 0));
97
+ break;
98
+ }
99
+ case "tail_bolt": {
100
+ const seg = (len: number) => new BoxGeometry(s * 0.28, len, s * 0.12);
101
+ g.add(mesh(seg(s * 0.9), mat(accent, accent, 0.6), 0, s * 0.3, s * 0.35, -0.9, 0, 0));
102
+ g.add(mesh(seg(s * 0.8), mat(accent, accent, 0.6), s * 0.18, s * 0.75, s * 0.7, 0.5, 0, 0.3));
103
+ g.add(mesh(seg(s * 1.1), mat(accent, accent, 0.6), 0, s * 1.2, s * 1.05, -0.9, 0, 0));
104
+ break;
105
+ }
106
+ case "wings":
107
+ for (const side of [-1, 1]) {
108
+ const wing = new BoxGeometry(s * 2.2, s * 0.9, s * 0.08);
109
+ wing.translate(side * s * 1.1, 0, 0);
110
+ g.add(mesh(wing, a, side * w * 0.08, 0, 0, 0, side * 0.5, side * 0.35));
111
+ }
112
+ break;
113
+ case "armor":
114
+ g.add(mesh(new BoxGeometry(w * 0.7, h * 0.28, w * 0.55), mat(0x9aa4b2, 0x101418, 0.2), 0, 0, 0));
115
+ break;
116
+ case "backpack":
117
+ g.add(mesh(new BoxGeometry(w * 0.45, h * 0.25, w * 0.25), a, 0, 0, w * 0.1));
118
+ break;
119
+ case "big_wheels":
120
+ // Applied to the model's wheel nodes (see prepare.ts), not added as geometry.
121
+ break;
122
+ }
123
+ return g;
124
+ }
125
+
126
+ /** Procedural pickups (no file): gems, cells. Geometry centred at the origin. */
127
+ export function proceduralAsset(name: string): { geometry: BufferGeometry; material: MeshStandardMaterial } | null {
128
+ // The voxel style's pickups are built from voxels ("voxel_gem_small").
129
+ if (name.startsWith("voxel_")) return voxel.voxelPickup(name.slice(6)) as { geometry: BufferGeometry; material: MeshStandardMaterial } | null;
130
+ switch (name) {
131
+ case "gem_small":
132
+ return { geometry: new OctahedronGeometry(0.16), material: mat(0xffc04d, 0xff8a00, 1.6) };
133
+ case "gem_mid":
134
+ return { geometry: new OctahedronGeometry(0.24), material: mat(0x7ad7ff, 0x1e90ff, 1.8) };
135
+ case "gem_large":
136
+ return { geometry: new IcosahedronGeometry(0.4), material: mat(0xff7ad9, 0xff2fb0, 2) };
137
+ case "cell_heal": {
138
+ const g = new OctahedronGeometry(0.3);
139
+ g.scale(1, 1.5, 1);
140
+ return { geometry: g, material: mat(0x7dff9a, 0x2bff6a, 1.8) };
141
+ }
142
+ case "cell_spark":
143
+ return { geometry: new IcosahedronGeometry(0.22), material: mat(0x7ad7ff, 0x1e9bff, 1.8) };
144
+ case "ring":
145
+ return { geometry: new TorusGeometry(0.3, 0.06, 6, 16), material: mat(0xffd34d, 0xff9a00, 1) };
146
+ default:
147
+ return null;
148
+ }
149
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Turn a loaded glTF into a library model: cloned (skeletons included),
3
+ * turned to face -z, scaled to its height, standing on y = 0, centred on
4
+ * x/z, its palette shifted toward the look's tint, and its look parts
5
+ * attached so they follow the animation.
6
+ */
7
+ import { Box3, Color, Group, Matrix4, Mesh, Object3D, Quaternion, SRGBColorSpace, Vector3 } from "three/webgpu";
8
+ import type { BufferGeometry, Material, MeshStandardMaterial } from "three/webgpu";
9
+ import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
10
+ import type { ResolvedLook } from "../look.js";
11
+ import { RIGS } from "../rigs.js";
12
+ import { buildPart } from "./parts.js";
13
+
14
+ export interface LoadedModel {
15
+ readonly scene: Object3D;
16
+ readonly animations: readonly import("three/webgpu").AnimationClip[];
17
+ }
18
+
19
+ export interface PreparedModel {
20
+ /** Add this to the scene. Origin at the feet, facing -z. */
21
+ readonly root: Group;
22
+ /** The cloned glTF scene inside root (what an AnimationMixer drives). */
23
+ readonly model: Object3D;
24
+ readonly height: number;
25
+ readonly width: number;
26
+ readonly depth: number;
27
+ /** The right-hand bone or node, for a held weapon (null when the rig has none). */
28
+ readonly hand: Object3D | null;
29
+ }
30
+
31
+ const tmpColor = new Color();
32
+ const hsl = { h: 0, s: 0, l: 0 };
33
+ const tintHsl = { h: 0, s: 0, l: 0 };
34
+
35
+ /** Shift a colour's hue toward the tint, keep its lightness; near-greys and near-whites (eyes, teeth) stay. */
36
+ export function shiftColor(c: Color, tint: number, keepSkin = false): void {
37
+ c.getHSL(hsl, SRGBColorSpace);
38
+ tmpColor.setHex(tint).getHSL(tintHsl, SRGBColorSpace);
39
+ if (hsl.s < 0.12 || hsl.l > 0.92 || hsl.l < 0.06) return;
40
+ // People keep their skin: warm, mid-light, moderately saturated tones stay as they are.
41
+ if (keepSkin && hsl.h >= 0.02 && hsl.h <= 0.12 && hsl.s <= 0.75 && hsl.l >= 0.35) return;
42
+ c.setHSL(tintHsl.h, Math.min(1, hsl.s * 0.4 + tintHsl.s * 0.6), hsl.l * 0.5 + tintHsl.l * 0.5, SRGBColorSpace);
43
+ }
44
+
45
+ /**
46
+ * Vertex-coloured models (the voxel style keeps each voxel's colour, with its
47
+ * occlusion baked in, per vertex): shift each colour's hue toward the tint,
48
+ * and scale lightness by one factor for the whole model so baked occlusion
49
+ * keeps its contrast. Near-greys and near-whites (eyes, teeth) stay.
50
+ */
51
+ function tintVertexColors(root: Object3D, tint: number, keepSkin: boolean): void {
52
+ const done = new Map<BufferGeometry, BufferGeometry>();
53
+ tmpColor.setHex(tint).getHSL(tintHsl, SRGBColorSpace);
54
+ const c = new Color();
55
+ root.traverse((o) => {
56
+ const m = o as Mesh;
57
+ if (!m.isMesh) return;
58
+ const src = m.geometry as BufferGeometry;
59
+ const attr = src.getAttribute("color");
60
+ if (!attr) return;
61
+ let g = done.get(src);
62
+ if (!g) {
63
+ g = src.clone();
64
+ const col = g.getAttribute("color");
65
+ let sum = 0;
66
+ for (let i = 0; i < col.count; i++) sum += c.setRGB(col.getX(i), col.getY(i), col.getZ(i)).getHSL(hsl, SRGBColorSpace).l;
67
+ const mean = col.count ? sum / col.count : 0.5;
68
+ const k = mean > 0.02 ? (mean * 0.5 + tintHsl.l * 0.5) / mean : 1;
69
+ for (let i = 0; i < col.count; i++) {
70
+ c.setRGB(col.getX(i), col.getY(i), col.getZ(i)).getHSL(hsl, SRGBColorSpace);
71
+ if (hsl.s < 0.12 || hsl.l > 0.92 || hsl.l < 0.04) continue;
72
+ if (keepSkin && hsl.h >= 0.02 && hsl.h <= 0.12 && hsl.s <= 0.75 && hsl.l >= 0.3) continue;
73
+ c.setHSL(tintHsl.h, Math.min(1, hsl.s * 0.4 + tintHsl.s * 0.6), Math.min(1, hsl.l * k), SRGBColorSpace);
74
+ col.setXYZ(i, c.r, c.g, c.b);
75
+ }
76
+ col.needsUpdate = true;
77
+ done.set(src, g);
78
+ }
79
+ m.geometry = g;
80
+ });
81
+ }
82
+
83
+ function tintMaterials(root: Object3D, tint: number, keepSkin: boolean): void {
84
+ const done = new Map<Material, Material>();
85
+ tintVertexColors(root, tint, keepSkin);
86
+ root.traverse((o) => {
87
+ const m = o as Mesh;
88
+ if (!m.isMesh) return;
89
+ const swap = (mat: Material): Material => {
90
+ let c = done.get(mat);
91
+ if (!c) {
92
+ c = mat.clone();
93
+ const sm = c as MeshStandardMaterial;
94
+ if (sm.color) {
95
+ if (sm.map) sm.color.lerp(tmpColor.setHex(tint), 0.3);
96
+ else shiftColor(sm.color, tint, keepSkin);
97
+ }
98
+ done.set(mat, c);
99
+ }
100
+ return c;
101
+ };
102
+ m.material = Array.isArray(m.material) ? m.material.map(swap) : swap(m.material);
103
+ });
104
+ }
105
+
106
+ function findNode(root: Object3D, re: RegExp | null): Object3D | null {
107
+ if (!re) return null;
108
+ let hit: Object3D | null = null;
109
+ root.traverse((o) => {
110
+ if (!hit && re.test(o.name)) hit = o;
111
+ });
112
+ return hit;
113
+ }
114
+
115
+ /** Parent `child` to `bone` keeping the world placement it has now (relative to `frame`). */
116
+ function attachKeepingPlacement(child: Object3D, bone: Object3D): void {
117
+ bone.updateWorldMatrix(true, false);
118
+ const inv = new Matrix4().copy(bone.matrixWorld).invert();
119
+ child.updateWorldMatrix(true, false);
120
+ const local = inv.multiply(child.matrixWorld);
121
+ const p = new Vector3();
122
+ const q = new Quaternion();
123
+ const s = new Vector3();
124
+ local.decompose(p, q, s);
125
+ child.removeFromParent();
126
+ bone.add(child);
127
+ child.position.copy(p);
128
+ child.quaternion.copy(q);
129
+ child.scale.copy(s);
130
+ }
131
+
132
+ /**
133
+ * Prepare a loaded model for drawing. `height` overrides the asset's own
134
+ * (enemies are sized from their hit radius); the look's scale multiplies it.
135
+ */
136
+ export function prepareModel(loaded: LoadedModel, look: ResolvedLook, opts: { height?: number } = {}): PreparedModel {
137
+ const asset = look.asset;
138
+ const model = cloneSkinned(loaded.scene);
139
+ const inner = new Group();
140
+ inner.add(model);
141
+ // glTF models face +z; the runtime's forward is -z.
142
+ inner.rotation.y = Math.PI + asset.yaw;
143
+ const root = new Group();
144
+ root.name = look.requested;
145
+ root.add(inner);
146
+ // Big wheels: grow the wheel nodes before measuring, so the truck stands on them.
147
+ if (look.parts.some((p) => p.part === "big_wheels")) {
148
+ const wheels: Object3D[] = [];
149
+ model.traverse((o) => {
150
+ // Outermost wheel nodes only, so a wheel and its child mesh are not both scaled.
151
+ if (/wheel/i.test(o.name) && !(o.parent && /wheel/i.test(o.parent.name))) wheels.push(o);
152
+ });
153
+ for (const w of wheels) w.scale.multiplyScalar(1.9);
154
+ }
155
+ root.updateMatrixWorld(true);
156
+ const box = new Box3().setFromObject(root, true);
157
+ const size = box.getSize(new Vector3());
158
+ const target = (opts.height ?? asset.height) * look.scale;
159
+ const k = size.y > 1e-6 ? target / size.y : 1;
160
+ inner.scale.setScalar(k);
161
+ const centre = box.getCenter(new Vector3());
162
+ inner.position.set(-centre.x * k, -box.min.y * k, -centre.z * k);
163
+ root.updateMatrixWorld(true);
164
+ const height = size.y * k;
165
+ const width = size.x * k;
166
+ const depth = size.z * k;
167
+ if (look.tint !== null) tintMaterials(model, look.tint, asset.kind === "character");
168
+
169
+ const rig = asset.rig ? RIGS[asset.rig] : undefined;
170
+ const head = findNode(model, rig?.headBone ?? null);
171
+ const headEnd = head ? findNode(model, /^head_end$/i) : null;
172
+ const body = findNode(model, /^(torso|chest|spine|body)/i);
173
+ const bodyColor = look.tint ?? 0xcccccc;
174
+ for (const p of look.parts) {
175
+ if (p.part === "big_wheels") continue;
176
+ const part = buildPart(p.part, height, Math.max(width, depth * 0.6), look.accent, bodyColor);
177
+ // Anchor points in the root's frame (facing -z): head on top, face in front, back and tail behind.
178
+ // Anchors in the root's frame (facing -z). The head bone, when the rig has one, places head and face parts;
179
+ // otherwise four-legged bodies carry their head at the front and bipeds on top.
180
+ const four = asset.plan === "quadruped";
181
+ const hp = head ? head.getWorldPosition(new Vector3()) : four ? new Vector3(0, height * 0.8, -depth * 0.4) : new Vector3(0, height * 0.85, 0);
182
+ // The head bone sits at the neck; its end (when the rig has one) is the crown.
183
+ const crown = headEnd ? headEnd.getWorldPosition(new Vector3()) : null;
184
+ const headTop = crown ? crown.y : head ? Math.min(height * 0.99, hp.y + height * (four ? 0.12 : 0.1)) : four ? height * 0.95 : height * 0.97;
185
+ const headSize = Math.max(0.05, headTop - (head ? hp.y : height * 0.75));
186
+ switch (p.anchor) {
187
+ case "head":
188
+ part.position.set(crown?.x ?? hp.x, headTop - headSize * 0.08, crown?.z ?? hp.z);
189
+ break;
190
+ case "face":
191
+ part.position.set(hp.x, head ? hp.y + headSize * 0.35 : height * 0.72, (head ? hp.z : 0) - (four ? depth * 0.12 : headSize * 0.62));
192
+ break;
193
+ case "back":
194
+ part.position.set(0, height * (four ? 0.7 : 0.62), four ? 0 : depth * 0.35);
195
+ break;
196
+ case "tail":
197
+ part.position.set(0, height * (four ? 0.55 : 0.3), depth * 0.48);
198
+ break;
199
+ default:
200
+ part.position.set(0, height * 0.5, 0);
201
+ }
202
+ root.add(part);
203
+ const bone = p.anchor === "head" || p.anchor === "face" ? head : p.anchor === "back" || p.anchor === "body" ? body : null;
204
+ if (bone) attachKeepingPlacement(part, bone);
205
+ }
206
+ return { root, model, height, width, depth, hand: findNode(model, rig?.handBone ?? null) };
207
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Vite plugin: serve the built library at ./library in dev and copy it into
3
+ * the build, so a template plays with real models offline once the library
4
+ * is built (or copied from @netforge/asset-library-files). With `ids`, only those assets
5
+ * (and the bases of variants) are copied: a draft build ships what it uses.
6
+ * In production the page reads VITE_LIBRARY_URL (the published CID) instead
7
+ * and nothing needs copying (`copy: false`).
8
+ */
9
+ import { existsSync, readFileSync, statSync } from "node:fs";
10
+ import { dirname, join, normalize, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import type { Plugin } from "vite";
13
+ import { assetById, baseAsset, library, soundById } from "./library.js";
14
+ import { assetPath, soundPath } from "./paths.js";
15
+
16
+ /** This package's root (the published package holds no scripts/, so this is found here rather than imported from them). */
17
+ const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
18
+
19
+ export const DEFAULT_LIBRARY_DIR = join(PACKAGE_DIR, ".library");
20
+
21
+ const TYPES: Readonly<Record<string, string>> = { glb: "model/gltf-binary", png: "image/png", webp: "image/webp", json: "application/json", ogg: "audio/ogg", mp3: "audio/mpeg" };
22
+
23
+ export interface LibraryPluginOptions {
24
+ readonly dir?: string;
25
+ /** Library ids to copy into the build; all of them when omitted. */
26
+ readonly ids?: () => readonly string[];
27
+ /** Copy into the build (default true). */
28
+ readonly copy?: boolean;
29
+ /** With ids: also copy the audio section (every sound, about 3.5 MB). Default true. */
30
+ readonly audio?: boolean;
31
+ }
32
+
33
+ /** Library paths for some ids: their files, the files of variant bases, and sounds (audio.*). */
34
+ export function filesFor(ids: readonly string[]): string[] {
35
+ const out = new Set<string>();
36
+ for (const id of ids) {
37
+ const s = soundById(id);
38
+ if (s) out.add(soundPath(s));
39
+ const a = assetById(id);
40
+ if (!a) continue;
41
+ const p = assetPath(baseAsset(a));
42
+ if (p) out.add(p);
43
+ }
44
+ return [...out].sort();
45
+ }
46
+
47
+ function allFiles(dir: string): string[] {
48
+ const index = join(dir, "index.json");
49
+ if (!existsSync(index)) return [];
50
+ const parsed = JSON.parse(readFileSync(index, "utf8")) as { assets: Record<string, { path: string }> };
51
+ return [...new Set(Object.values(parsed.assets).map((a) => a.path))].sort();
52
+ }
53
+
54
+ export function assetLibrary(o: LibraryPluginOptions = {}): Plugin {
55
+ const dir = resolve(o.dir ?? DEFAULT_LIBRARY_DIR);
56
+ return {
57
+ name: "netnet-asset-library",
58
+ configureServer(server) {
59
+ server.middlewares.use((req, res, next) => {
60
+ const url = (req.url ?? "").split("?")[0] ?? "";
61
+ const i = url.indexOf("/library/");
62
+ if (i < 0) return next();
63
+ const rel = normalize(decodeURIComponent(url.slice(i + "/library/".length)));
64
+ const file = join(dir, rel);
65
+ if (!file.startsWith(dir) || !existsSync(file) || !statSync(file).isFile()) {
66
+ res.statusCode = 404;
67
+ res.end("not in the built library (see @netforge/asset-library-files)");
68
+ return;
69
+ }
70
+ res.setHeader("content-type", TYPES[rel.split(".").pop() ?? ""] ?? "application/octet-stream");
71
+ res.setHeader("cache-control", "no-cache");
72
+ res.end(readFileSync(file));
73
+ });
74
+ },
75
+ generateBundle() {
76
+ if (o.copy === false) return;
77
+ if (!existsSync(dir)) {
78
+ this.warn(`no built asset library at ${dir}; the build draws primitive shapes (pass the library folder as dir; @netforge/asset-library-files has the built library)`);
79
+ return;
80
+ }
81
+ const files = o.ids ? filesFor([...o.ids(), ...(o.audio === false ? [] : library().sounds.keys())]) : allFiles(dir);
82
+ for (const f of files) {
83
+ const src = join(dir, f);
84
+ if (!existsSync(src)) continue;
85
+ this.emitFile({ type: "asset", fileName: `library/${f}`, source: readFileSync(src) });
86
+ }
87
+ },
88
+ };
89
+ }