@homie-rocks/studio 0.21.0 → 0.23.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.
- package/CHANGELOG.md +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/assets/assets.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @homie-rocks/studio/assets — ONE MODEL LOADER FOR EVERY three.js GAME A STUDIO MAKES.
|
|
3
|
+
*
|
|
4
|
+
* import { createModels } from '@homie-rocks/studio/assets';
|
|
5
|
+
* const models = createModels(); // one per game
|
|
6
|
+
* const fox = await models.instance('./models/fox.glb'); // a copy to place (skinned models too)
|
|
7
|
+
* scene.add(fox);
|
|
8
|
+
*
|
|
9
|
+
* Every model is a self-contained glTF binary (.glb) from the game's own files (games/<id>/public/models/), made
|
|
10
|
+
* phone-sized by `homie-studio assets add` or `assets optimise`: meshopt geometry and WebP pictures, decoded here.
|
|
11
|
+
* Before three.js parses a byte, the file is checked (assets/safety.mjs, the same rules `assets check` and
|
|
12
|
+
* `assets add` use): a file over the size cap, a buffer or picture loaded from an address (an external URI), an
|
|
13
|
+
* accessor reaching past its buffer or an extension three.js cannot read is REFUSED. A refused or missing model
|
|
14
|
+
* rejects with a plain-words Error; `models.placeholder()` is a grey box of the size it would have been, so a round
|
|
15
|
+
* never waits on a model.
|
|
16
|
+
*
|
|
17
|
+
* Models load from the game's own origin only (relative addresses): a game never depends on another site at
|
|
18
|
+
* runtime (homie.rocks's starter library is copied into the game, never hot-linked). `allowOrigins` opens one on
|
|
19
|
+
* purpose.
|
|
20
|
+
*
|
|
21
|
+
* In development (localhost, or ?debug=1) a model over its budget says so in the console, and `stats()` (also
|
|
22
|
+
* `window.__homieModels`) has the totals the perf skill reads: models, triangles, texture memory and refusals.
|
|
23
|
+
*
|
|
24
|
+
* KTX2 pictures (KHR_texture_basisu) need a transcoder (about 580 KB): pass your own KTX2Loader as `ktx2` when a
|
|
25
|
+
* game ships them (`assets check` says when); WebP, the default, needs nothing.
|
|
26
|
+
*
|
|
27
|
+
* Things a game repeats (trees, gems, a fence) are instanced, one draw call per mesh however many there are:
|
|
28
|
+
*
|
|
29
|
+
* const copies = instancedCopies((await models.load('./models/tree.glb')).scene, 40);
|
|
30
|
+
* copies.forEach((c) => { placeCopy(c, 0, where); c.mesh.count = 1; scene.add(c.mesh); });
|
|
31
|
+
*
|
|
32
|
+
* Never bake a mesh's own transform into its geometry for that (geometry.applyMatrix4): an optimised model's positions
|
|
33
|
+
* are meshopt-quantized int16, dequantized by its node's scale, and a baked scale clips everything past one metre.
|
|
34
|
+
*
|
|
35
|
+
* A flat-coloured model (a library item with `paletteSwap`: its colours in one tiny palette picture) is repainted
|
|
36
|
+
* into a game's own palette with `repaint(model, pick)`, once, on the loaded model, before it is copied.
|
|
37
|
+
*
|
|
38
|
+
* `stylize(model, style)` draws it the way the game's art direction says (style.json: `materials.model` toon, flat,
|
|
39
|
+
* hand-painted or pbr, and `materials.outline`), the same material model and ink line the style board was drawn with.
|
|
40
|
+
* Once, on the loaded model, after `repaint` and before it is copied: its outline hulls are instanced with it.
|
|
41
|
+
*/
|
|
42
|
+
import { BackSide, Box3, BoxGeometry, BufferGeometry, Float32BufferAttribute, CanvasTexture, Color, DataTexture, InstancedMesh, Matrix4, Mesh, MeshBasicMaterial, MeshLambertMaterial, MeshStandardMaterial, MeshToonMaterial, NearestFilter, RedFormat, SRGBColorSpace, Vector3, type AnimationClip, type Group, type Material, type Object3D, type Texture } from 'three';
|
|
43
|
+
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
44
|
+
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
|
|
45
|
+
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
46
|
+
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
47
|
+
import { LIMITS, checkGlb, refusal } from './safety.mjs';
|
|
48
|
+
|
|
49
|
+
export { LIMITS, checkGlb } from './safety.mjs';
|
|
50
|
+
|
|
51
|
+
/** The size caps a file is checked against (assets/safety.mjs LIMITS.game unless a game narrows or widens them). */
|
|
52
|
+
export interface ModelLimits { bytes: number; json: number; buffer: number; dataUri: number; vertices: number; meshes: number; nodes: number; materials: number; images: number; textures: number; animations: number; skins: number; accessors: number }
|
|
53
|
+
|
|
54
|
+
/** What one model may cost on a phone (a prop 1,500 triangles and 512 px; a hero 8,000 and 1,024 px). */
|
|
55
|
+
export interface ModelBudget { triangles?: number; texturePx?: number; materials?: number; bytes?: number }
|
|
56
|
+
|
|
57
|
+
export interface LoadedModel {
|
|
58
|
+
url: string;
|
|
59
|
+
/** The model as loaded: never add this one to a scene twice; `instance()` gives copies. */
|
|
60
|
+
scene: Group;
|
|
61
|
+
animations: AnimationClip[];
|
|
62
|
+
bytes: number;
|
|
63
|
+
triangles: number;
|
|
64
|
+
textures: number;
|
|
65
|
+
/** GPU memory of its pictures, RGBA with mipmaps (what `gpuSize` in gltf-transform's inspect says). */
|
|
66
|
+
textureBytes: number;
|
|
67
|
+
warnings: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ModelsOptions {
|
|
71
|
+
/** Narrow or widen the file checks (LIMITS.game by default). */
|
|
72
|
+
limits?: Partial<ModelLimits>;
|
|
73
|
+
/** A per-model budget for the development warnings (the prop tier by default). */
|
|
74
|
+
budget?: ModelBudget;
|
|
75
|
+
/** A KTX2Loader the game set up (transcoder path, renderer support): only for models with KTX2 pictures. */
|
|
76
|
+
ktx2?: { load?: unknown } | null;
|
|
77
|
+
/** Origins a game deliberately loads models from, besides its own. */
|
|
78
|
+
allowOrigins?: string[];
|
|
79
|
+
/** Where development warnings go (console.warn by default; outside development, nowhere). */
|
|
80
|
+
onWarn?: (text: string) => void;
|
|
81
|
+
/** Say budget warnings even outside development. */
|
|
82
|
+
dev?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ModelStats { models: number; triangles: number; textureBytes: number; bytes: number; refused: { url: string; why: string }[] }
|
|
86
|
+
|
|
87
|
+
export interface Models {
|
|
88
|
+
/** Load (once per address) and check one model. Rejects with a plain-words Error when it is refused or missing. */
|
|
89
|
+
load(url: string, opts?: { limits?: Partial<ModelLimits>; budget?: ModelBudget }): Promise<LoadedModel>;
|
|
90
|
+
/** A copy of a loaded model to place in the scene (skeletons cloned properly). */
|
|
91
|
+
instance(url: string, opts?: { limits?: Partial<ModelLimits>; budget?: ModelBudget }): Promise<Object3D>;
|
|
92
|
+
/** A grey box (metres), for a model that was refused, is missing or is still loading. */
|
|
93
|
+
placeholder(size?: { x: number; y: number; z: number }, colour?: number): Mesh;
|
|
94
|
+
/** Totals for the perf probe and the console. */
|
|
95
|
+
stats(): ModelStats;
|
|
96
|
+
/** Free every geometry, material and picture this loader made. */
|
|
97
|
+
dispose(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PROP_BUDGET: Required<ModelBudget> = { triangles: 1500, texturePx: 512, materials: 1, bytes: 300 * 1024 };
|
|
101
|
+
const devHost = (): boolean => {
|
|
102
|
+
try { return /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname) || new URLSearchParams(location.search).get('debug') === '1'; } catch { return false; }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Where an address points: relative ones resolve against the page; another origin is refused unless allowed. */
|
|
106
|
+
function resolveUrl(url: string, allow: string[]): string {
|
|
107
|
+
const base = typeof location !== 'undefined' ? location.href : 'http://localhost/';
|
|
108
|
+
const u = new URL(url, base);
|
|
109
|
+
const own = typeof location !== 'undefined' ? location.origin : u.origin;
|
|
110
|
+
if (u.origin !== own && !allow.includes(u.origin)) throw new Error(`refused ${url}: a model from another site (${u.origin}); copy it into the game's own files (homie-studio assets add)`);
|
|
111
|
+
if (!/^https?:$/.test(u.protocol) && u.protocol !== 'blob:') throw new Error(`refused ${url}: not a web address`);
|
|
112
|
+
return u.href;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The file's bytes, refusing early when it says (or turns out) to be over the cap. */
|
|
116
|
+
async function fetchCapped(url: string, cap: number): Promise<Uint8Array> {
|
|
117
|
+
const res = await fetch(url, { credentials: 'same-origin' });
|
|
118
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
119
|
+
const said = Number(res.headers.get('content-length') ?? NaN);
|
|
120
|
+
// A served file may be compressed on the wire: the header is only trusted when the body is not.
|
|
121
|
+
if (Number.isFinite(said) && !res.headers.get('content-encoding') && said > cap) throw new Error(`refused ${url}: ${Math.ceil(said / 1024)} KB, over ${Math.ceil(cap / 1024)} KB`);
|
|
122
|
+
if (!res.body) { const b = new Uint8Array(await res.arrayBuffer()); if (b.byteLength > cap) throw new Error(`refused ${url}: over ${Math.ceil(cap / 1024)} KB`); return b; }
|
|
123
|
+
const reader = res.body.getReader();
|
|
124
|
+
const parts: Uint8Array[] = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
for (;;) {
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done) break;
|
|
129
|
+
total += value.byteLength;
|
|
130
|
+
if (total > cap) { void reader.cancel(); throw new Error(`refused ${url}: over ${Math.ceil(cap / 1024)} KB`); }
|
|
131
|
+
parts.push(value);
|
|
132
|
+
}
|
|
133
|
+
const out = new Uint8Array(total);
|
|
134
|
+
let at = 0;
|
|
135
|
+
for (const p of parts) { out.set(p, at); at += p.byteLength; }
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** GPU bytes of one picture: RGBA8 with mipmaps (x 4/3), or the compressed size a KTX2 upload names. */
|
|
140
|
+
function textureBytes(t: Texture): number {
|
|
141
|
+
const img = t.image as { width?: number; height?: number } | undefined;
|
|
142
|
+
const w = Number(img?.width ?? 0); const h = Number(img?.height ?? 0);
|
|
143
|
+
return Math.round(w * h * 4 * (4 / 3));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Instanced copies of one mesh of a model, and the mesh's own place in the model (`base`). */
|
|
147
|
+
export interface Copies { mesh: InstancedMesh; base: Matrix4 }
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A loaded model (`(await models.load(url)).scene`, or a placeholder) as instanced copies: one InstancedMesh per mesh
|
|
151
|
+
* in it, `count` copies at most, sharing the file's geometry and materials. Place copy `i` with placeCopy (where it
|
|
152
|
+
* goes, times the mesh's own place in the model), then set `mesh.count`.
|
|
153
|
+
*/
|
|
154
|
+
export function instancedCopies(model: Object3D, count: number): Copies[] {
|
|
155
|
+
model.updateMatrixWorld(true);
|
|
156
|
+
const out: Copies[] = [];
|
|
157
|
+
model.traverse((o) => {
|
|
158
|
+
const m = o as Mesh;
|
|
159
|
+
if (m.isMesh) { const mesh = new InstancedMesh(m.geometry, m.material, count); mesh.name = m.name; out.push({ mesh, base: m.matrixWorld.clone() }); }
|
|
160
|
+
});
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const placed = new Matrix4();
|
|
165
|
+
/** Copy `i` of `copy` at `where` (a world matrix: position, turn, scale). */
|
|
166
|
+
export function placeCopy(copy: Copies, i: number, where: Matrix4): void {
|
|
167
|
+
copy.mesh.setMatrixAt(i, placed.multiplyMatrices(where, copy.base));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Repaint a model's flat colours. `pick(r, g, b)` (sRGB, 0 to 255) returns the colour to paint instead, or null to
|
|
172
|
+
* keep it. It reaches each material's own colour and its base-colour picture when that is a small palette picture (at
|
|
173
|
+
* most `maxPx` a side: an optimised flat model's); a painted texture bigger than that is left as it is. Materials and
|
|
174
|
+
* pictures change in place, so call it once on the loaded model (`(await models.load(url)).scene`), before copying.
|
|
175
|
+
* Returns how many materials changed.
|
|
176
|
+
*/
|
|
177
|
+
export function repaint(model: Object3D, pick: (r: number, g: number, b: number) => [number, number, number] | null, { maxPx = 64 } = {}): number {
|
|
178
|
+
let changed = 0;
|
|
179
|
+
const seen = new Set<Material>();
|
|
180
|
+
const c = new Color();
|
|
181
|
+
model.traverse((o) => {
|
|
182
|
+
const m = o as Mesh;
|
|
183
|
+
if (!m.isMesh) return;
|
|
184
|
+
for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as MeshStandardMaterial[]) {
|
|
185
|
+
if (seen.has(mat)) continue;
|
|
186
|
+
seen.add(mat);
|
|
187
|
+
const tex = mat.map;
|
|
188
|
+
if (!tex) {
|
|
189
|
+
if (!mat.color) continue;
|
|
190
|
+
c.copy(mat.color).convertLinearToSRGB();
|
|
191
|
+
const to = pick(Math.round(c.r * 255), Math.round(c.g * 255), Math.round(c.b * 255));
|
|
192
|
+
if (to) { mat.color.setRGB(to[0] / 255, to[1] / 255, to[2] / 255, SRGBColorSpace); changed += 1; }
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const img = tex.image as (CanvasImageSource & { width?: number; height?: number }) | undefined;
|
|
196
|
+
const w = Number(img?.width ?? 0); const h = Number(img?.height ?? 0);
|
|
197
|
+
if (!img || !w || !h || w > maxPx || h > maxPx || typeof document === 'undefined') continue;
|
|
198
|
+
const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
|
|
199
|
+
const g = canvas.getContext('2d', { willReadFrequently: true });
|
|
200
|
+
if (!g) continue;
|
|
201
|
+
g.drawImage(img, 0, 0);
|
|
202
|
+
const px = g.getImageData(0, 0, w, h);
|
|
203
|
+
let n = 0;
|
|
204
|
+
for (let i = 0; i < px.data.length; i += 4) {
|
|
205
|
+
const to = pick(px.data[i] as number, px.data[i + 1] as number, px.data[i + 2] as number);
|
|
206
|
+
if (to) { px.data[i] = to[0]; px.data[i + 1] = to[1]; px.data[i + 2] = to[2]; n += 1; }
|
|
207
|
+
}
|
|
208
|
+
if (!n) continue;
|
|
209
|
+
g.putImageData(px, 0, 0);
|
|
210
|
+
// The new picture samples exactly as the old one did (its orientation, wrapping and filters).
|
|
211
|
+
const t = new CanvasTexture(canvas);
|
|
212
|
+
Object.assign(t, { colorSpace: tex.colorSpace, flipY: tex.flipY, wrapS: tex.wrapS, wrapT: tex.wrapT, magFilter: tex.magFilter, minFilter: tex.minFilter, generateMipmaps: tex.generateMipmaps, channel: tex.channel });
|
|
213
|
+
mat.map = t; mat.needsUpdate = true;
|
|
214
|
+
changed += 1;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return changed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Every geometry, material and picture under `obj`, freed. */
|
|
221
|
+
/** What `stylize` reads from a game's style.json. */
|
|
222
|
+
export interface StyleLike {
|
|
223
|
+
render?: string;
|
|
224
|
+
materials?: { model?: string; outline?: boolean };
|
|
225
|
+
palette?: { bg?: string; ink?: string };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let toonRamp: DataTexture | null = null;
|
|
229
|
+
function rampOf(): DataTexture {
|
|
230
|
+
if (toonRamp) return toonRamp;
|
|
231
|
+
const data = new Uint8Array([Math.round(0.2 * 255), Math.round(0.53 * 255), Math.round(0.87 * 255)]);
|
|
232
|
+
toonRamp = new DataTexture(data, 3, 1, RedFormat);
|
|
233
|
+
toonRamp.minFilter = NearestFilter; toonRamp.magFilter = NearestFilter; toonRamp.needsUpdate = true;
|
|
234
|
+
return toonRamp;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One material in the style's material model (its colour, picture, vertex colours and transparency kept). */
|
|
238
|
+
function styledMaterial(mat: Material, model: string): Material {
|
|
239
|
+
const src = mat as MeshStandardMaterial;
|
|
240
|
+
const base = { color: src.color ? src.color.clone() : new Color('#cccccc'), map: (src.map ?? null) as Texture | null, vertexColors: Boolean(src.vertexColors), transparent: src.transparent, opacity: src.opacity, side: src.side, alphaTest: src.alphaTest };
|
|
241
|
+
let out: Material;
|
|
242
|
+
if (model === 'toon') out = new MeshToonMaterial({ ...base, gradientMap: rampOf() });
|
|
243
|
+
else if (model === 'flat' || model === 'pixel') out = new MeshLambertMaterial({ ...base, flatShading: true });
|
|
244
|
+
else if (model === 'hand-painted') {
|
|
245
|
+
const m = new MeshStandardMaterial({ ...base, roughness: 0.95, metalness: 0 });
|
|
246
|
+
// A painted look: the top of every surface a little lighter, the bottom a little cooler.
|
|
247
|
+
m.onBeforeCompile = (sh) => {
|
|
248
|
+
sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvH = (modelMatrix * vec4(transformed, 1.0)).y;');
|
|
249
|
+
sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <color_fragment>', '#include <color_fragment>\ndiffuseColor.rgb *= mix(vec3(0.82, 0.86, 0.98), vec3(1.12, 1.06, 0.96), clamp(vH * 0.6 + 0.4, 0.0, 1.0));');
|
|
250
|
+
};
|
|
251
|
+
out = m;
|
|
252
|
+
} else return mat;
|
|
253
|
+
out.name = mat.name;
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The outline's shell: the mesh's positions only, its corners welded, with smooth normals, so a flat-shaded low-poly
|
|
259
|
+
* model's line stays one unbroken shell instead of a black flake on every face. One per geometry.
|
|
260
|
+
*/
|
|
261
|
+
const hullGeometries = new WeakMap<BufferGeometry, BufferGeometry | null>();
|
|
262
|
+
function hullGeometry(g: BufferGeometry): BufferGeometry | null {
|
|
263
|
+
if (hullGeometries.has(g)) return hullGeometries.get(g) ?? null;
|
|
264
|
+
let h: BufferGeometry | null;
|
|
265
|
+
const only = new BufferGeometry();
|
|
266
|
+
// Plain float positions (an optimised model's are quantized, often interleaved), read through the accessor.
|
|
267
|
+
const src = g.getAttribute('position');
|
|
268
|
+
const xyz = new Float32Array(src.count * 3);
|
|
269
|
+
for (let i = 0; i < src.count; i++) { xyz[i * 3] = src.getX(i); xyz[i * 3 + 1] = src.getY(i); xyz[i * 3 + 2] = src.getZ(i); }
|
|
270
|
+
only.setAttribute('position', new Float32BufferAttribute(xyz, 3));
|
|
271
|
+
if (g.index) only.setIndex(Array.from(g.index.array as ArrayLike<number>));
|
|
272
|
+
h = mergeVertices(only);
|
|
273
|
+
if (openShell(h) || paperThin(h)) h = null; else h.computeVertexNormals();
|
|
274
|
+
hullGeometries.set(g, h);
|
|
275
|
+
return h;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* How thick a closed shell is, about (3 x volume / area: a slab of thickness t gives 1.5 t), in its own units.
|
|
280
|
+
*/
|
|
281
|
+
function thicknessOf(h: BufferGeometry): number {
|
|
282
|
+
const p = h.getAttribute('position'); const a = (h.index as NonNullable<BufferGeometry['index']>).array as ArrayLike<number>;
|
|
283
|
+
let vol = 0; let area = 0;
|
|
284
|
+
for (let i = 0; i + 2 < a.length; i += 3) {
|
|
285
|
+
const i0 = a[i] as number; const i1 = a[i + 1] as number; const i2 = a[i + 2] as number;
|
|
286
|
+
const ax = p.getX(i0); const ay = p.getY(i0); const az = p.getZ(i0);
|
|
287
|
+
const bx = p.getX(i1); const by = p.getY(i1); const bz = p.getZ(i1);
|
|
288
|
+
const cx = p.getX(i2); const cy = p.getY(i2); const cz = p.getZ(i2);
|
|
289
|
+
vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
|
|
290
|
+
const ux = bx - ax; const uy = by - ay; const uz = bz - az; const vx = cx - ax; const vy = cy - ay; const vz = cz - az;
|
|
291
|
+
area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
|
|
292
|
+
}
|
|
293
|
+
return area > 0 ? (3 * Math.abs(vol)) / area : 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Two cards back to back (leaves, grass): closed, but with no inside, so a shell would z-fight it black. */
|
|
297
|
+
function paperThin(h: BufferGeometry): boolean {
|
|
298
|
+
h.computeBoundingBox();
|
|
299
|
+
const size = (h.boundingBox as NonNullable<BufferGeometry['boundingBox']>).getSize(new Vector3());
|
|
300
|
+
return thicknessOf(h) < 0.01 * Math.max(size.x, size.y, size.z);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** An open surface (cards of leaves, grass, a sail): many edges belong to one triangle only. No outline shell for it. */
|
|
304
|
+
function openShell(h: BufferGeometry): boolean {
|
|
305
|
+
const idx = h.index;
|
|
306
|
+
if (!idx) return true;
|
|
307
|
+
const edges = new Map<string, number>();
|
|
308
|
+
const a = idx.array as ArrayLike<number>;
|
|
309
|
+
for (let i = 0; i + 2 < a.length; i += 3) {
|
|
310
|
+
for (const [u, v] of [[a[i], a[i + 1]], [a[i + 1], a[i + 2]], [a[i + 2], a[i]]] as [number, number][]) {
|
|
311
|
+
const k = u < v ? `${u}_${v}` : `${v}_${u}`;
|
|
312
|
+
edges.set(k, (edges.get(k) ?? 0) + 1);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
let open = 0;
|
|
316
|
+
for (const n of edges.values()) if (n === 1) open += 1;
|
|
317
|
+
return edges.size > 0 && open / edges.size > 0.15;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The ink shell's vertex step: pushed out along its normal by `width` WORLD units (metres in a game), after every
|
|
322
|
+
* transform (the model's scale, a quantized mesh's node, an instance's matrix), so a line is as thick on a flower as on
|
|
323
|
+
* a tree and nothing about the model's own units matters.
|
|
324
|
+
*/
|
|
325
|
+
function inkShader(width: number): (sh: { vertexShader: string }) => void {
|
|
326
|
+
const w = Math.max(0, width).toExponential(4);
|
|
327
|
+
return (sh) => {
|
|
328
|
+
sh.vertexShader = sh.vertexShader.replace('#include <project_vertex>', [
|
|
329
|
+
'vec4 mvPosition = vec4( transformed, 1.0 );',
|
|
330
|
+
'vec3 inkN = normal;',
|
|
331
|
+
'#ifdef USE_INSTANCING',
|
|
332
|
+
'mvPosition = instanceMatrix * mvPosition; inkN = mat3( instanceMatrix ) * inkN;',
|
|
333
|
+
'#endif',
|
|
334
|
+
'vec4 inkW = modelMatrix * mvPosition;',
|
|
335
|
+
`inkW.xyz += normalize( mat3( modelMatrix ) * inkN ) * ${w};`,
|
|
336
|
+
'mvPosition = viewMatrix * inkW;',
|
|
337
|
+
'gl_Position = projectionMatrix * mvPosition;',
|
|
338
|
+
].join('\n'));
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const MODEL_OF_RENDER: Record<string, string> = { toon: 'toon', 'lowpoly-flat': 'flat', voxel: 'flat', 'pixel-hd2d': 'pixel', 'hand-painted': 'hand-painted', pbr: 'pbr', realistic: 'pbr' };
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Draw a loaded model the way the game's art direction says: its material model (style.json `materials.model`, else
|
|
346
|
+
* the render style's) on every mesh, and with `materials.outline` an ink line (an inverted hull: a back-faced copy
|
|
347
|
+
* pushed out along the normals, `line` metres in the world, as thick on a bush as on a tree, finer on something small:
|
|
348
|
+
* pass `scale`, the scale the game draws it at). Skinned meshes, open cards and paper-thin shells get the material,
|
|
349
|
+
* not the line. Call it once on the loaded model, after `repaint`; `instancedCopies` copies the line with it. Returns how many
|
|
350
|
+
* meshes changed.
|
|
351
|
+
*/
|
|
352
|
+
export function stylize(model: Object3D, style: StyleLike, { line = 0.05, scale = 1, ink }: { line?: number; scale?: number; ink?: string } = {}): number {
|
|
353
|
+
const kind = style.materials?.model ?? MODEL_OF_RENDER[String(style.render ?? '')] ?? 'pbr';
|
|
354
|
+
const swapped = new Map<Material, Material>();
|
|
355
|
+
const meshes: Mesh[] = [];
|
|
356
|
+
model.traverse((o) => { const m = o as Mesh; if (m.isMesh && m.name !== 'hull') meshes.push(m); });
|
|
357
|
+
for (const m of meshes) {
|
|
358
|
+
const swap = (x: Material): Material => { let y = swapped.get(x); if (!y) { y = styledMaterial(x, kind); swapped.set(x, y); } return y; };
|
|
359
|
+
m.material = Array.isArray(m.material) ? m.material.map(swap) : swap(m.material);
|
|
360
|
+
}
|
|
361
|
+
if (style.materials?.outline) {
|
|
362
|
+
const colour = new Color(ink ?? (style.palette?.bg ? `#${new Color(style.palette.bg).lerp(new Color('#000000'), 0.7).getHexString()}` : '#1a1410'));
|
|
363
|
+
// A small thing (a flower, a gem) gets a finer line: never more than 6% of its size as the game draws it (`scale`).
|
|
364
|
+
const box = new Box3().setFromObject(model).getSize(new Vector3());
|
|
365
|
+
const width = Math.min(line, 0.06 * Math.max(box.x, box.y, box.z) * scale);
|
|
366
|
+
const inkMaterial = new MeshBasicMaterial({ color: colour, side: BackSide });
|
|
367
|
+
inkMaterial.onBeforeCompile = inkShader(width);
|
|
368
|
+
inkMaterial.customProgramCacheKey = () => `ink:${width}`;
|
|
369
|
+
for (const m of meshes) {
|
|
370
|
+
// No line on a skinned mesh; cards of leaves and grass (open, or two faces back to back) get none from hullGeometry.
|
|
371
|
+
if ((m as unknown as { isSkinnedMesh?: boolean }).isSkinnedMesh) continue;
|
|
372
|
+
const shell = hullGeometry(m.geometry);
|
|
373
|
+
if (!shell) continue;
|
|
374
|
+
// A part too thin for the line (a flower's stem, a post) goes without: three widths of ink would make it a stick.
|
|
375
|
+
shell.computeBoundingBox();
|
|
376
|
+
const local = (shell.boundingBox as Box3).getSize(new Vector3());
|
|
377
|
+
const world = new Box3().setFromObject(m, true).getSize(new Vector3());
|
|
378
|
+
const units = Math.max(world.x, world.y, world.z) / Math.max(1e-9, local.x, local.y, local.z);
|
|
379
|
+
if (thicknessOf(shell) * units * scale < 3 * width) continue;
|
|
380
|
+
const hull = new Mesh(shell, inkMaterial);
|
|
381
|
+
hull.name = 'hull';
|
|
382
|
+
hull.castShadow = false; hull.receiveShadow = false;
|
|
383
|
+
m.add(hull);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return meshes.length;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function disposeObject(obj: Object3D): void {
|
|
390
|
+
obj.traverse((o) => {
|
|
391
|
+
const m = o as Mesh;
|
|
392
|
+
if (m.geometry) m.geometry.dispose();
|
|
393
|
+
const mats = (Array.isArray(m.material) ? m.material : m.material ? [m.material] : []) as Material[];
|
|
394
|
+
for (const mat of mats) {
|
|
395
|
+
for (const v of Object.values(mat as unknown as Record<string, unknown>)) if (v && typeof v === 'object' && (v as Texture).isTexture) (v as Texture).dispose();
|
|
396
|
+
mat.dispose();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function createModels(opts: ModelsOptions = {}): Models {
|
|
402
|
+
const limits = { ...LIMITS.game, ...(opts.limits ?? {}) } as ModelLimits;
|
|
403
|
+
const allow = (opts.allowOrigins ?? []).map((o) => new URL(o).origin);
|
|
404
|
+
const dev = opts.dev ?? devHost();
|
|
405
|
+
const warn = (text: string): void => { if (dev) (opts.onWarn ?? ((t: string) => console.warn(`[models] ${t}`)))(text); };
|
|
406
|
+
const loader = new GLTFLoader();
|
|
407
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
408
|
+
if (opts.ktx2) loader.setKTX2Loader(opts.ktx2 as never);
|
|
409
|
+
const cache = new Map<string, Promise<LoadedModel>>();
|
|
410
|
+
const made: Object3D[] = [];
|
|
411
|
+
const refused: { url: string; why: string }[] = [];
|
|
412
|
+
const loaded: LoadedModel[] = [];
|
|
413
|
+
|
|
414
|
+
async function loadOnce(url: string, lim: ModelLimits, budget: Required<ModelBudget>): Promise<LoadedModel> {
|
|
415
|
+
let href: string;
|
|
416
|
+
try { href = resolveUrl(url, allow); } catch (error) { refused.push({ url, why: (error as Error).message }); throw error; }
|
|
417
|
+
let bytes: Uint8Array;
|
|
418
|
+
try { bytes = await fetchCapped(href, lim.bytes); } catch (error) { refused.push({ url, why: (error as Error).message }); throw error; }
|
|
419
|
+
const check = checkGlb(bytes, lim);
|
|
420
|
+
if (!check.ok) { const why = refusal(url, check); refused.push({ url, why }); throw new Error(why); }
|
|
421
|
+
if (check.info.extensions.required.includes('KHR_texture_basisu') && !opts.ktx2) {
|
|
422
|
+
const why = `refused ${url}: it has KTX2 pictures and this game set up no KTX2 loader (createModels({ ktx2 }))`;
|
|
423
|
+
refused.push({ url, why });
|
|
424
|
+
throw new Error(why);
|
|
425
|
+
}
|
|
426
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
427
|
+
const gltf = await new Promise<GLTF>((resolve, reject) => loader.parse(buffer, href.replace(/[^/]*$/, ''), resolve, reject));
|
|
428
|
+
const scene = gltf.scene;
|
|
429
|
+
let triangles = 0; let textures = 0; let tex = 0; let materials = 0;
|
|
430
|
+
const seen = new Set<string>();
|
|
431
|
+
scene.traverse((o) => {
|
|
432
|
+
const m = o as Mesh;
|
|
433
|
+
if (!m.isMesh) return;
|
|
434
|
+
const g = m.geometry;
|
|
435
|
+
const count = g.index ? g.index.count : (g.attributes.position?.count ?? 0);
|
|
436
|
+
triangles += Math.floor(count / 3) * Number((m as unknown as { count?: number }).count ?? 1);
|
|
437
|
+
for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as Material[]) {
|
|
438
|
+
if (seen.has(mat.uuid)) continue;
|
|
439
|
+
seen.add(mat.uuid); materials += 1;
|
|
440
|
+
for (const v of Object.values(mat as unknown as Record<string, unknown>)) {
|
|
441
|
+
const t = v as Texture;
|
|
442
|
+
if (t && typeof t === 'object' && t.isTexture && !seen.has(t.uuid)) { seen.add(t.uuid); textures += 1; tex += textureBytes(t); }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
const warnings = [...check.warnings];
|
|
447
|
+
if (triangles > budget.triangles) warnings.push(`${triangles.toLocaleString('en-US')} triangles (its budget is ${budget.triangles.toLocaleString('en-US')})`);
|
|
448
|
+
if (materials > budget.materials) warnings.push(`${materials} materials (its budget is ${budget.materials})`);
|
|
449
|
+
if (bytes.byteLength > budget.bytes) warnings.push(`${Math.ceil(bytes.byteLength / 1024)} KB (its budget is ${Math.ceil(budget.bytes / 1024)} KB)`);
|
|
450
|
+
const px = budget.texturePx * budget.texturePx * 4 * (4 / 3) * Math.max(1, textures);
|
|
451
|
+
if (tex > px) warnings.push(`${(tex / 1024 / 1024).toFixed(1)} MB of picture memory (pictures over ${budget.texturePx} px)`);
|
|
452
|
+
for (const w of warnings) warn(`${url}: ${w}`);
|
|
453
|
+
const model: LoadedModel = { url, scene, animations: gltf.animations ?? [], bytes: bytes.byteLength, triangles, textures, textureBytes: tex, warnings };
|
|
454
|
+
loaded.push(model);
|
|
455
|
+
made.push(scene);
|
|
456
|
+
return model;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const api: Models = {
|
|
460
|
+
load(url, o = {}) {
|
|
461
|
+
const key = url;
|
|
462
|
+
let p = cache.get(key);
|
|
463
|
+
if (!p) {
|
|
464
|
+
p = loadOnce(url, { ...limits, ...(o.limits ?? {}) } as ModelLimits, { ...PROP_BUDGET, ...(opts.budget ?? {}), ...(o.budget ?? {}) });
|
|
465
|
+
cache.set(key, p);
|
|
466
|
+
p.catch(() => cache.delete(key));
|
|
467
|
+
}
|
|
468
|
+
return p;
|
|
469
|
+
},
|
|
470
|
+
async instance(url, o) {
|
|
471
|
+
const m = await api.load(url, o);
|
|
472
|
+
let skinned = false;
|
|
473
|
+
m.scene.traverse((x) => { if ((x as unknown as { isSkinnedMesh?: boolean }).isSkinnedMesh) skinned = true; });
|
|
474
|
+
return skinned ? cloneSkinned(m.scene) : m.scene.clone(true);
|
|
475
|
+
},
|
|
476
|
+
placeholder(size = { x: 0.5, y: 0.5, z: 0.5 }, colour = 0x8a8f99) {
|
|
477
|
+
const geo = new BoxGeometry(size.x, size.y, size.z);
|
|
478
|
+
geo.translate(0, size.y / 2, 0);
|
|
479
|
+
const box = new Mesh(geo, new MeshStandardMaterial({ color: colour, roughness: 0.9 }));
|
|
480
|
+
box.name = 'placeholder';
|
|
481
|
+
made.push(box);
|
|
482
|
+
return box;
|
|
483
|
+
},
|
|
484
|
+
stats() {
|
|
485
|
+
return {
|
|
486
|
+
models: loaded.length,
|
|
487
|
+
triangles: loaded.reduce((n, m) => n + m.triangles, 0),
|
|
488
|
+
textureBytes: loaded.reduce((n, m) => n + m.textureBytes, 0),
|
|
489
|
+
bytes: loaded.reduce((n, m) => n + m.bytes, 0),
|
|
490
|
+
refused: refused.slice(),
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
dispose() {
|
|
494
|
+
for (const o of made) disposeObject(o);
|
|
495
|
+
made.length = 0; loaded.length = 0; cache.clear();
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
try { (globalThis as unknown as { __homieModels?: () => ModelStats }).__homieModels = () => api.stats(); } catch { /* not a browser */ }
|
|
499
|
+
return api;
|
|
500
|
+
}
|