@threenative/core 0.3.0 → 0.3.2

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 (39) hide show
  1. package/README.md +10 -0
  2. package/capabilities.json +1881 -131
  3. package/dist/assets-kyoF7JlJ.d.ts +103 -0
  4. package/dist/{audio-Dp2mXpD3.d.ts → audio-BFiGneTL.d.ts} +62 -0
  5. package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
  6. package/dist/{game-CYIaKhgl.d.ts → game-XGrTzapq.d.ts} +350 -164
  7. package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
  8. package/dist/hot.d.ts +5 -3
  9. package/dist/hot.js +5 -1
  10. package/dist/index.d.ts +813 -143
  11. package/dist/index.js +4548 -941
  12. package/dist/net.d.ts +65 -0
  13. package/dist/net.js +643 -0
  14. package/dist/playtest.d.ts +29 -5
  15. package/dist/playtest.js +181 -35
  16. package/dist/react.d.ts +4 -2
  17. package/dist/{canvas-layer-CtrZHgIh.d.ts → renderer-C6hqZpoG.d.ts} +237 -75
  18. package/dist/world.d.ts +203 -4
  19. package/dist/world.js +2536 -25
  20. package/gpl/LICENSE.GPL +117 -0
  21. package/gpl/convert.py +192 -0
  22. package/gpl/recipes/_common.py +169 -0
  23. package/gpl/recipes/bake_ao.py +111 -0
  24. package/gpl/recipes/decimate.py +64 -0
  25. package/gpl/recipes/retarget.py +131 -0
  26. package/gpl/recipes/unwrap.py +71 -0
  27. package/mcp/blender-server.mjs +632 -0
  28. package/mcp/blender.mjs +27 -0
  29. package/mcp/engine-server.mjs +271 -23
  30. package/mcp/engine.mjs +15 -7
  31. package/mcp/install.d.mts +37 -0
  32. package/mcp/install.mjs +94 -26
  33. package/mcp/servers.d.mts +34 -0
  34. package/mcp/servers.mjs +110 -9
  35. package/package.json +36 -8
  36. package/patches/three@0.185.1.patch +249 -14
  37. package/scripts/ensure-mcp.mjs +20 -13
  38. package/scripts/bundle-engine-mcp.mjs +0 -15
  39. package/scripts/generate-version.mjs +0 -13
package/mcp/servers.mjs CHANGED
@@ -24,6 +24,13 @@ export const MCP_SERVERS = Object.freeze({
24
24
  command: "node",
25
25
  args: Object.freeze(["./node_modules/@threenative/core/mcp/engine.mjs"]),
26
26
  }),
27
+ // Drives an installed Blender headlessly so a downloaded .fbx becomes a GLB. It ships no
28
+ // Blender and probes for one only when a tool is called, so a project that never converts a
29
+ // model pays nothing for having this server wired.
30
+ "threenative-blender": Object.freeze({
31
+ command: "node",
32
+ args: Object.freeze(["./node_modules/@threenative/core/mcp/blender.mjs"]),
33
+ }),
27
34
  });
28
35
 
29
36
  /** The external package each shim launches. Core installs asset and sculpt transitively and bundles
@@ -31,15 +38,66 @@ export const MCP_SERVERS = Object.freeze({
31
38
  * separately. The pinned engine version remains an emergency fallback for a legacy development
32
39
  * checkout whose bundle has not been built. */
33
40
  export const MCP_PACKAGES = Object.freeze({
34
- assets: Object.freeze({ name: "threenative-asset-mcp", version: "0.6.0" }),
35
- sculpt: Object.freeze({ name: "threenative-sculpt-mcp", version: "0.1.0" }),
36
- engine: Object.freeze({ name: "threenative-engine-mcp", version: "0.2.0" }),
41
+ assets: Object.freeze({ name: "threenative-asset-mcp", version: "0.8.0" }),
42
+ sculpt: Object.freeze({ name: "threenative-sculpt-mcp", version: "0.1.1" }),
43
+ engine: Object.freeze({ name: "threenative-engine-mcp", version: "0.2.2" }),
44
+ blender: Object.freeze({ name: "threenative-blender-mcp", version: "0.1.2" }),
45
+ });
46
+
47
+ /** How one host spells a stdio MCP server. Hosts agree on what to run and disagree on where to
48
+ * write it down, so the servers are declared once above and translated here. `key` is the property
49
+ * the host reads its server table from; `entry` is one server in that host's own shape. */
50
+ const SERVER_FORMATS = Object.freeze({
51
+ // Claude Code (`.mcp.json`), Cursor and the Gemini CLI all read this one.
52
+ mcpServers: Object.freeze({
53
+ key: "mcpServers",
54
+ entry: (server) => ({
55
+ command: server.command,
56
+ args: [...server.args],
57
+ ...(server.env === undefined ? {} : { env: { ...server.env } }),
58
+ }),
59
+ }),
60
+ // VS Code names the table `servers` and requires an explicit transport per server.
61
+ vscode: Object.freeze({
62
+ key: "servers",
63
+ entry: (server) => ({
64
+ type: "stdio",
65
+ command: server.command,
66
+ args: [...server.args],
67
+ ...(server.env === undefined ? {} : { env: { ...server.env } }),
68
+ }),
69
+ }),
70
+ // opencode folds the command and its arguments into one array and spells the environment
71
+ // `environment`. A server it does not mark enabled is written down and never started.
72
+ opencode: Object.freeze({
73
+ key: "mcp",
74
+ entry: (server) => ({
75
+ type: "local",
76
+ command: [server.command, ...server.args],
77
+ enabled: true,
78
+ ...(server.env === undefined ? {} : { environment: { ...server.env } }),
79
+ }),
80
+ }),
81
+ // Zed calls them context servers and needs `source: "custom"` to accept a command of our own.
82
+ zed: Object.freeze({
83
+ key: "context_servers",
84
+ entry: (server) => ({
85
+ source: "custom",
86
+ command: server.command,
87
+ args: [...server.args],
88
+ ...(server.env === undefined ? {} : { env: { ...server.env } }),
89
+ }),
90
+ }),
37
91
  });
38
92
 
93
+ export const SERVER_FORMAT_NAMES = Object.freeze(Object.keys(SERVER_FORMATS));
94
+
39
95
  /** Adds the ThreeNative servers to an existing config without touching anything else in it, and
40
96
  * reports whether the file needs rewriting. Fails closed on a config that is not an object: an
41
- * unreadable `.mcp.json` is the user's, and overwriting it would lose servers we did not write. */
42
- export function mergeMcpServers(existing) {
97
+ * unreadable config is the user's, and overwriting it would lose servers we did not write. */
98
+ export function mergeMcpServers(existing, format = "mcpServers") {
99
+ const shape = SERVER_FORMATS[format];
100
+ if (shape === undefined) throw new Error(`Unknown MCP config format '${format}'.`);
43
101
  const malformed =
44
102
  existing !== undefined &&
45
103
  (typeof existing !== "object" || existing === null || Array.isArray(existing));
@@ -47,13 +105,56 @@ export function mergeMcpServers(existing) {
47
105
  throw new Error("MCP config root must be an object.");
48
106
  }
49
107
  const previous = existing ?? {};
50
- const servers = { ...(previous.mcpServers ?? {}) };
108
+ const existingServers = previous[shape.key];
109
+ if (
110
+ existingServers !== undefined &&
111
+ (typeof existingServers !== "object" ||
112
+ existingServers === null ||
113
+ Array.isArray(existingServers))
114
+ ) {
115
+ throw new Error(`MCP config '${shape.key}' must be an object.`);
116
+ }
117
+ const servers = { ...(existingServers ?? {}) };
118
+ for (const [name, server] of Object.entries(servers)) {
119
+ if (typeof server !== "object" || server === null || Array.isArray(server)) {
120
+ throw new Error(`MCP server '${name}' in '${shape.key}' must be an object.`);
121
+ }
122
+ }
51
123
  let changed = false;
124
+ const conflicts = [];
52
125
  for (const [name, server] of Object.entries(MCP_SERVERS)) {
53
- const wanted = JSON.parse(JSON.stringify(server));
54
- if (JSON.stringify(servers[name]) === JSON.stringify(wanted)) continue;
126
+ const wanted = shape.entry(server);
127
+ if (Object.hasOwn(servers, name)) {
128
+ if (JSON.stringify(servers[name]) !== JSON.stringify(wanted)) conflicts.push(name);
129
+ continue;
130
+ }
55
131
  servers[name] = wanted;
56
132
  changed = true;
57
133
  }
58
- return { changed, config: { ...previous, mcpServers: servers } };
134
+ return { changed, conflicts, config: { ...previous, [shape.key]: servers } };
59
135
  }
136
+
137
+ /** One server as a Codex TOML table. Derived from `MCP_SERVERS` rather than written out beside it:
138
+ * two hand-maintained lists of the same three servers drift, and the drift is silent until an
139
+ * agent in Codex quietly has no asset tools. */
140
+ function codexServerBlock(name, server) {
141
+ const args = server.args.map((argument) => JSON.stringify(argument)).join(", ");
142
+ const lines = [
143
+ `[mcp_servers.${name}]`,
144
+ `command = ${JSON.stringify(server.command)}`,
145
+ `args = [${args}]`,
146
+ ];
147
+ if (server.env !== undefined) {
148
+ lines.push("", `[mcp_servers.${name}.env]`);
149
+ for (const [key, value] of Object.entries(server.env)) {
150
+ lines.push(`${key} = ${JSON.stringify(value)}`);
151
+ }
152
+ }
153
+ return lines.join("\n");
154
+ }
155
+
156
+ export const CODEX_MCP_SERVERS = Object.freeze(
157
+ Object.entries(MCP_SERVERS).map(([name, server]) =>
158
+ Object.freeze({ body: codexServerBlock(name, server), name }),
159
+ ),
160
+ );
package/package.json CHANGED
@@ -1,13 +1,36 @@
1
1
  {
2
2
  "name": "@threenative/core",
3
- "version": "0.3.0",
4
- "license": "MIT",
3
+ "version": "0.3.2",
4
+ "license": "MIT AND GPL-2.0-or-later",
5
5
  "description": "The small vanilla Three.js game runtime foundation.",
6
+ "keywords": [
7
+ "threenative",
8
+ "three",
9
+ "threejs",
10
+ "webgpu",
11
+ "game-engine",
12
+ "game-framework",
13
+ "gamedev",
14
+ "typescript"
15
+ ],
16
+ "homepage": "https://github.com/ThreeNativeHQ/threenative#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/ThreeNativeHQ/threenative/issues"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/ThreeNativeHQ/threenative.git",
23
+ "directory": "packages/core"
24
+ },
6
25
  "type": "module",
7
26
  "files": [
8
27
  "dist",
28
+ "LICENSE",
9
29
  "mcp",
10
- "scripts",
30
+ "gpl",
31
+ "scripts/apply-three-patch.mjs",
32
+ "scripts/ensure-mcp.mjs",
33
+ "scripts/postinstall.mjs",
11
34
  "patches",
12
35
  "capabilities.json",
13
36
  "README.md"
@@ -40,6 +63,10 @@
40
63
  "types": "./dist/world.d.ts",
41
64
  "import": "./dist/world.js"
42
65
  },
66
+ "./net": {
67
+ "types": "./dist/net.d.ts",
68
+ "import": "./dist/net.js"
69
+ },
43
70
  "./package.json": "./package.json"
44
71
  },
45
72
  "publishConfig": {
@@ -49,7 +76,7 @@
49
76
  "dependencies": {
50
77
  "three": "0.185.1",
51
78
  "three-mesh-bvh": "0.9.14",
52
- "threenative-asset-mcp": "0.6.0",
79
+ "threenative-asset-mcp": "0.8.0",
53
80
  "threenative-sculpt-mcp": "0.1.1",
54
81
  "zustand": "5.0.14"
55
82
  },
@@ -62,11 +89,12 @@
62
89
  "react-reconciler": "0.33.0",
63
90
  "tsup": "8.5.1",
64
91
  "typescript": "5.9.3",
65
- "@threenative/playtest": "0.3.0",
66
- "threenative-engine-mcp": "0.2.0"
92
+ "threenative-blender-mcp": "0.1.2",
93
+ "@threenative/playtest": "0.3.2",
94
+ "threenative-engine-mcp": "0.2.2"
67
95
  },
68
96
  "peerDependencies": {
69
- "@threenative/playtest": ">=0.3.0 <0.4.0",
97
+ "@threenative/playtest": ">=0.3.2 <0.4.0",
70
98
  "react": ">=19.2.0 <20",
71
99
  "react-reconciler": ">=0.33.0 <0.34"
72
100
  },
@@ -83,7 +111,7 @@
83
111
  },
84
112
  "scripts": {
85
113
  "postinstall": "node ./scripts/postinstall.mjs",
86
- "build": "node scripts/generate-version.mjs && tsup --config tsup.config.ts && node scripts/bundle-engine-mcp.mjs && publint",
114
+ "build": "node scripts/generate-version.mjs && tsup --config tsup.config.ts && node scripts/bundle-engine-mcp.mjs && node scripts/bundle-blender-mcp.mjs && publint",
87
115
  "typecheck": "tsc --noEmit",
88
116
  "test": "publint --strict"
89
117
  }
@@ -1,5 +1,5 @@
1
1
  diff --git a/build/three.cjs b/build/three.cjs
2
- index 8939a67d..b0f91724 100644
2
+ index 8939a67dd76325ccd25ee8806fc4d22b1caea88a..b0f917241f1f2cd25baa21dec20edda32176ef6a 100644
3
3
  --- a/build/three.cjs
4
4
  +++ b/build/three.cjs
5
5
  @@ -26116,6 +26116,8 @@ function copyArrayContents( src, target ) {
@@ -12,7 +12,7 @@ index 8939a67d..b0f91724 100644
12
12
  * Constructs a new batched mesh.
13
13
  *
14
14
  diff --git a/build/three.core.js b/build/three.core.js
15
- index 0bb9262c..868a634b 100644
15
+ index 0bb9262cd029f411933d077fd44197a51ec5e8e9..868a634b4294be5f32664912a50813d55d6dd6e7 100644
16
16
  --- a/build/three.core.js
17
17
  +++ b/build/three.core.js
18
18
  @@ -26136,6 +26136,8 @@ function copyArrayContents( src, target ) {
@@ -25,7 +25,7 @@ index 0bb9262c..868a634b 100644
25
25
  * Constructs a new batched mesh.
26
26
  *
27
27
  diff --git a/build/three.webgpu.js b/build/three.webgpu.js
28
- index 2e490796..325b497a 100644
28
+ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..d05599255744ccfca19dbfd0433da612583ce6ea 100644
29
29
  --- a/build/three.webgpu.js
30
30
  +++ b/build/three.webgpu.js
31
31
  @@ -18500,7 +18500,8 @@ const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder ) => {
@@ -86,7 +86,56 @@ index 2e490796..325b497a 100644
86
86
 
87
87
  }
88
88
 
89
- @@ -39725,7 +39742,8 @@ class VelocityNode extends TempNode {
89
+ @@ -29911,6 +29928,18 @@ class RenderObject {
90
+ */
91
+ this._sourceMaterial = renderer._currentSourceMaterial;
92
+
93
+ + /**
94
+ + * The version of `_sourceMaterial` this render object was last validated against.
95
+ + *
96
+ + * An override material is shared by every object in the pass and is used as a per-object
97
+ + * scratchpad, so its own `version` moves for reasons that have nothing to do with this
98
+ + * render object. The source material's version is the signal that does.
99
+ + *
100
+ + * @type {number}
101
+ + * @default 0
102
+ + */
103
+ + this.sourceVersion = ( this._sourceMaterial === null || this._sourceMaterial === undefined ) ? 0 : this._sourceMaterial.version;
104
+ +
105
+ /**
106
+ * An event listener which is defined by `RenderObjects`. It performs
107
+ * clean up tasks when `dispose()` on this render object.
108
+ @@ -30707,9 +30736,18 @@ class RenderObjects {
109
+
110
+ }
111
+
112
+ - if ( renderObject.version !== material.version || renderObject.needsUpdate ) {
113
+ + // When the renderer is drawing through an override material, that material is shared by
114
+ + // the whole pass and mutated per object, so its `version` alone answers the wrong
115
+ + // question. The source material this object actually carries answers the right one.
116
+ +
117
+ + const sourceMaterial = this.renderer._currentSourceMaterial;
118
+ + const sourceTracked = sourceMaterial !== null && sourceMaterial !== undefined;
119
+ + const sourceReplaced = sourceTracked && renderObject._sourceMaterial !== sourceMaterial;
120
+ + const sourceChanged = sourceTracked && ( sourceReplaced || renderObject.sourceVersion !== sourceMaterial.version );
121
+
122
+ - if ( renderObject.initialCacheKey !== renderObject.getCacheKey() ) {
123
+ + if ( renderObject.version !== material.version || sourceChanged || renderObject.needsUpdate ) {
124
+ +
125
+ + if ( sourceReplaced || renderObject.initialCacheKey !== renderObject.getCacheKey() ) {
126
+
127
+ renderObject.dispose();
128
+
129
+ @@ -30719,6 +30757,8 @@ class RenderObjects {
130
+
131
+ renderObject.version = material.version;
132
+
133
+ + if ( sourceTracked ) renderObject.sourceVersion = sourceMaterial.version;
134
+ +
135
+ }
136
+
137
+ }
138
+ @@ -39725,7 +39765,8 @@ class VelocityNode extends TempNode {
90
139
 
91
140
  const previousModelMatrix = getPreviousMatrix( object );
92
141
 
@@ -96,7 +145,7 @@ index 2e490796..325b497a 100644
96
145
 
97
146
  //
98
147
 
99
- @@ -53841,7 +53859,7 @@ class NodeBuilder {
148
+ @@ -53841,7 +53882,7 @@ class NodeBuilder {
100
149
 
101
150
  const mrt = this.renderer.getMRT();
102
151
 
@@ -105,7 +154,58 @@ index 2e490796..325b497a 100644
105
154
 
106
155
  }
107
156
 
108
- @@ -83814,6 +83832,14 @@ class WebGPUBackend extends Backend {
157
+ @@ -62731,6 +62772,7 @@ class Renderer {
158
+ let materialDisplacementMap;
159
+ let materialDisplacementScale;
160
+ let materialDisplacementBias;
161
+ + let materialAlphaTest;
162
+
163
+ const previousSourceMaterial = this._currentSourceMaterial;
164
+
165
+ @@ -62756,6 +62798,7 @@ class Renderer {
166
+ materialDisplacementMap = overrideMaterial.displacementMap;
167
+ materialDisplacementScale = overrideMaterial.displacementScale;
168
+ materialDisplacementBias = overrideMaterial.displacementBias;
169
+ + materialAlphaTest = overrideMaterial.alphaTest;
170
+
171
+ if ( material.positionNode && material.positionNode.isNode ) {
172
+
173
+ @@ -62763,7 +62806,15 @@ class Renderer {
174
+
175
+ }
176
+
177
+ - overrideMaterial.alphaTest = material.alphaTest;
178
+ + // `Material.alphaTest`'s setter bumps `version` whenever alpha-test-ness flips, and this
179
+ + // override material is shared by every caster in the pass. A scene that mixes alpha-tested
180
+ + // casters with opaque ones therefore moves that shared version several times per frame,
181
+ + // and every OTHER render object drawn with it then fails the version gate in
182
+ + // `RenderObjects.get` and recomputes a full material cache key that always matches.
183
+ + // Writing the backing field leaves the `alphaTest` getter — and so the cache key — still
184
+ + // reporting this caster's value, while each render object tracks the staleness signal
185
+ + // that belongs to it: its own source material's version.
186
+ + overrideMaterial._alphaTest = material.alphaTest;
187
+ overrideMaterial.alphaMap = material.alphaMap;
188
+ overrideMaterial.displacementMap = material.displacementMap;
189
+ overrideMaterial.displacementScale = material.displacementScale;
190
+ @@ -62825,6 +62876,7 @@ class Renderer {
191
+ scene.overrideMaterial.displacementMap = materialDisplacementMap;
192
+ scene.overrideMaterial.displacementScale = materialDisplacementScale;
193
+ scene.overrideMaterial.displacementBias = materialDisplacementBias;
194
+ + scene.overrideMaterial._alphaTest = materialAlphaTest;
195
+
196
+ }
197
+
198
+ @@ -79586,7 +79638,8 @@ class WGSLNodeBuilder extends NodeBuilder {
199
+
200
+ uniformGPU = buffer;
201
+
202
+ - uniformNode.name = name ? name : 'NodeBuffer_' + uniformNode.id;
203
+ + // The base builder supplies a unique name within this program. A process-wide
204
+ + // allocation id changes otherwise identical shader source for every buffer.
205
+
206
+ } else {
207
+
208
+ @@ -83814,6 +83867,14 @@ class WebGPUBackend extends Backend {
109
209
  */
110
210
  this.compatibilityMode = null;
111
211
 
@@ -120,7 +220,7 @@ index 2e490796..325b497a 100644
120
220
  /**
121
221
  * A reference to the device.
122
222
  *
123
- @@ -83926,7 +83952,9 @@ class WebGPUBackend extends Backend {
223
+ @@ -83926,7 +83987,9 @@ class WebGPUBackend extends Backend {
124
224
  xrCompatible: renderer.xr.enabled
125
225
  };
126
226
 
@@ -132,7 +232,7 @@ index 2e490796..325b497a 100644
132
232
  if ( adapter === null ) {
133
233
 
134
234
  diff --git a/src/nodes/accessors/Batch.js b/src/nodes/accessors/Batch.js
135
- index ab62e98d..2cf4bb60 100644
235
+ index ab62e98dadab2539d7e1d663fd42267bbc8ebcaa..2cf4bb6039853d5c922e89041419250b243694ef 100644
136
236
  --- a/src/nodes/accessors/Batch.js
137
237
  +++ b/src/nodes/accessors/Batch.js
138
238
  @@ -1,6 +1,6 @@
@@ -178,7 +278,7 @@ index ab62e98d..2cf4bb60 100644
178
278
  -
179
279
  -
180
280
  diff --git a/src/nodes/accessors/Instance.js b/src/nodes/accessors/Instance.js
181
- index b89834b9..c7b7bdd7 100644
281
+ index b89834b9166c75fd946ade24db265bf8c367d244..c7b7bdd7a31d852a581d4bc75a1e1afc1d214e5e 100644
182
282
  --- a/src/nodes/accessors/Instance.js
183
283
  +++ b/src/nodes/accessors/Instance.js
184
284
  @@ -220,7 +220,8 @@ export const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder
@@ -192,7 +292,7 @@ index b89834b9..c7b7bdd7 100644
192
292
  } );
193
293
 
194
294
  diff --git a/src/nodes/accessors/Skinning.js b/src/nodes/accessors/Skinning.js
195
- index 2356aed8..aaeea633 100644
295
+ index 2356aed8656dceb1ea74b73ea2e739371a960484..aaeea633a8ece291be5faf868e312867ddad01b6 100644
196
296
  --- a/src/nodes/accessors/Skinning.js
197
297
  +++ b/src/nodes/accessors/Skinning.js
198
298
  @@ -149,7 +149,8 @@ export const skinning = /*@__PURE__*/ Fn( ( [ skinnedMesh ], builder ) => {
@@ -216,7 +316,7 @@ index 2356aed8..aaeea633 100644
216
316
  }
217
317
 
218
318
  diff --git a/src/nodes/accessors/VelocityNode.js b/src/nodes/accessors/VelocityNode.js
219
- index 373548b0..c48002ab 100644
319
+ index 373548b0b587e7da3ef0993bfe316af420e7f66c..c48002abbce20ee8b29908cf314d7b03afe0db03 100644
220
320
  --- a/src/nodes/accessors/VelocityNode.js
221
321
  +++ b/src/nodes/accessors/VelocityNode.js
222
322
  @@ -106,7 +106,8 @@ class VelocityNode extends TempNode {
@@ -230,7 +330,7 @@ index 373548b0..c48002ab 100644
230
330
  //
231
331
 
232
332
  diff --git a/src/nodes/core/NodeBuilder.js b/src/nodes/core/NodeBuilder.js
233
- index be00b81e..1990da8f 100644
333
+ index be00b81ef36e1af83b0066603e616f8f1f81a115..1990da8fe8f1f29a7b53030756dfd966d8de3cb6 100644
234
334
  --- a/src/nodes/core/NodeBuilder.js
235
335
  +++ b/src/nodes/core/NodeBuilder.js
236
336
  @@ -3450,7 +3450,7 @@ class NodeBuilder {
@@ -243,7 +343,7 @@ index be00b81e..1990da8f 100644
243
343
  }
244
344
 
245
345
  diff --git a/src/objects/BatchedMesh.js b/src/objects/BatchedMesh.js
246
- index 2ef7cb25..35cc0ffe 100644
346
+ index 2ef7cb257438826059eb2408b3756c4891f06a10..35cc0ffe83a69ea9fa5fca6dec2d820ca193f88d 100644
247
347
  --- a/src/objects/BatchedMesh.js
248
348
  +++ b/src/objects/BatchedMesh.js
249
349
  @@ -181,6 +181,8 @@ function copyArrayContents( src, target ) {
@@ -255,8 +355,129 @@ index 2ef7cb25..35cc0ffe 100644
255
355
  /**
256
356
  * Constructs a new batched mesh.
257
357
  *
358
+ diff --git a/src/renderers/common/Bindings.js b/src/renderers/common/Bindings.js
359
+ index 5f3d213a016b37db3b2e5d022da3dd0234d86f6d..7a9a168f29773f37ef9a51ef9f07ade9f3ea95ef 100644
360
+ --- a/src/renderers/common/Bindings.js
361
+ +++ b/src/renderers/common/Bindings.js
362
+ @@ -206,10 +206,14 @@ class Bindings extends DataMap {
363
+
364
+ } else if ( binding.isSampledTexture ) {
365
+
366
+ + // Async node compilation can yield while a viewport node changes targets.
367
+ + // Refresh its reference before creating the first GPU binding, not after.
368
+ + binding.update();
369
+ this.textures.updateTexture( binding.texture );
370
+
371
+ } else if ( binding.isSampler ) {
372
+
373
+ + binding.update();
374
+ this.textures.updateSampler( binding );
375
+
376
+ } else if ( binding.isStorageBuffer ) {
377
+ diff --git a/src/renderers/common/RenderObject.js b/src/renderers/common/RenderObject.js
378
+ index c900fa4c9654f8cf3512e96de52a2c1c83e189a6..d0a87eabffc8f667ebe9b2f0eccda0ddf3309652 100644
379
+ --- a/src/renderers/common/RenderObject.js
380
+ +++ b/src/renderers/common/RenderObject.js
381
+ @@ -299,6 +299,18 @@ class RenderObject {
382
+ */
383
+ this._sourceMaterial = renderer._currentSourceMaterial;
384
+
385
+ + /**
386
+ + * The version of `_sourceMaterial` this render object was last validated against.
387
+ + *
388
+ + * An override material is shared by every object in the pass and is used as a per-object
389
+ + * scratchpad, so its own `version` moves for reasons that have nothing to do with this
390
+ + * render object. The source material's version is the signal that does.
391
+ + *
392
+ + * @type {number}
393
+ + * @default 0
394
+ + */
395
+ + this.sourceVersion = ( this._sourceMaterial === null || this._sourceMaterial === undefined ) ? 0 : this._sourceMaterial.version;
396
+ +
397
+ /**
398
+ * An event listener which is defined by `RenderObjects`. It performs
399
+ * clean up tasks when `dispose()` on this render object.
400
+ diff --git a/src/renderers/common/RenderObjects.js b/src/renderers/common/RenderObjects.js
401
+ index 676cc2bd4169427ed420f2467b1e6684bd2aeebc..0fa661db793548254a15ff43f39207abcc43ef49 100644
402
+ --- a/src/renderers/common/RenderObjects.js
403
+ +++ b/src/renderers/common/RenderObjects.js
404
+ @@ -124,9 +124,18 @@ class RenderObjects {
405
+
406
+ }
407
+
408
+ - if ( renderObject.version !== material.version || renderObject.needsUpdate ) {
409
+ + // When the renderer is drawing through an override material, that material is shared by
410
+ + // the whole pass and mutated per object, so its `version` alone answers the wrong
411
+ + // question. The source material this object actually carries answers the right one.
412
+
413
+ - if ( renderObject.initialCacheKey !== renderObject.getCacheKey() ) {
414
+ + const sourceMaterial = this.renderer._currentSourceMaterial;
415
+ + const sourceTracked = sourceMaterial !== null && sourceMaterial !== undefined;
416
+ + const sourceReplaced = sourceTracked && renderObject._sourceMaterial !== sourceMaterial;
417
+ + const sourceChanged = sourceTracked && ( sourceReplaced || renderObject.sourceVersion !== sourceMaterial.version );
418
+ +
419
+ + if ( renderObject.version !== material.version || sourceChanged || renderObject.needsUpdate ) {
420
+ +
421
+ + if ( sourceReplaced || renderObject.initialCacheKey !== renderObject.getCacheKey() ) {
422
+
423
+ renderObject.dispose();
424
+
425
+ @@ -136,6 +145,8 @@ class RenderObjects {
426
+
427
+ renderObject.version = material.version;
428
+
429
+ + if ( sourceTracked ) renderObject.sourceVersion = sourceMaterial.version;
430
+ +
431
+ }
432
+
433
+ }
434
+ diff --git a/src/renderers/common/Renderer.js b/src/renderers/common/Renderer.js
435
+ index 8dfc2ae055ad7b9d4c00e91f7d52a39e747caebe..e9d27b3ddad3b1f080a717d1f6683b60afd2b259 100644
436
+ --- a/src/renderers/common/Renderer.js
437
+ +++ b/src/renderers/common/Renderer.js
438
+ @@ -3550,6 +3550,7 @@ class Renderer {
439
+ let materialDisplacementMap;
440
+ let materialDisplacementScale;
441
+ let materialDisplacementBias;
442
+ + let materialAlphaTest;
443
+
444
+ const previousSourceMaterial = this._currentSourceMaterial;
445
+
446
+ @@ -3575,6 +3576,7 @@ class Renderer {
447
+ materialDisplacementMap = overrideMaterial.displacementMap;
448
+ materialDisplacementScale = overrideMaterial.displacementScale;
449
+ materialDisplacementBias = overrideMaterial.displacementBias;
450
+ + materialAlphaTest = overrideMaterial.alphaTest;
451
+
452
+ if ( material.positionNode && material.positionNode.isNode ) {
453
+
454
+ @@ -3582,7 +3584,15 @@ class Renderer {
455
+
456
+ }
457
+
458
+ - overrideMaterial.alphaTest = material.alphaTest;
459
+ + // `Material.alphaTest`'s setter bumps `version` whenever alpha-test-ness flips, and this
460
+ + // override material is shared by every caster in the pass. A scene that mixes alpha-tested
461
+ + // casters with opaque ones therefore moves that shared version several times per frame,
462
+ + // and every OTHER render object drawn with it then fails the version gate in
463
+ + // `RenderObjects.get` and recomputes a full material cache key that always matches.
464
+ + // Writing the backing field leaves the `alphaTest` getter — and so the cache key — still
465
+ + // reporting this caster's value, while each render object tracks the staleness signal
466
+ + // that belongs to it: its own source material's version.
467
+ + overrideMaterial._alphaTest = material.alphaTest;
468
+ overrideMaterial.alphaMap = material.alphaMap;
469
+ overrideMaterial.displacementMap = material.displacementMap;
470
+ overrideMaterial.displacementScale = material.displacementScale;
471
+ @@ -3644,6 +3654,7 @@ class Renderer {
472
+ scene.overrideMaterial.displacementMap = materialDisplacementMap;
473
+ scene.overrideMaterial.displacementScale = materialDisplacementScale;
474
+ scene.overrideMaterial.displacementBias = materialDisplacementBias;
475
+ + scene.overrideMaterial._alphaTest = materialAlphaTest;
476
+
477
+ }
478
+
258
479
  diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js
259
- index 06e20369..37b5e0be 100644
480
+ index 06e203693ed9b168a2bddf773cd8ce8dc23196e4..37b5e0be09967e8acbd38e570b71eb201fc9bad7 100644
260
481
  --- a/src/renderers/webgpu/WebGPUBackend.js
261
482
  +++ b/src/renderers/webgpu/WebGPUBackend.js
262
483
  @@ -102,6 +102,14 @@ class WebGPUBackend extends Backend {
@@ -285,3 +506,17 @@ index 06e20369..37b5e0be 100644
285
506
 
286
507
  if ( adapter === null ) {
287
508
 
509
+ diff --git a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js
510
+ index be07536301cd47ef2544e4a54f6abe17407f52af..6144b98ddeb5d53c5cf280bb51401385a4427547 100644
511
+ --- a/src/renderers/webgpu/nodes/WGSLNodeBuilder.js
512
+ +++ b/src/renderers/webgpu/nodes/WGSLNodeBuilder.js
513
+ @@ -1323,7 +1323,8 @@ class WGSLNodeBuilder extends NodeBuilder {
514
+
515
+ uniformGPU = buffer;
516
+
517
+ - uniformNode.name = name ? name : 'NodeBuffer_' + uniformNode.id;
518
+ + // The base builder supplies a unique name within this program. A process-wide
519
+ + // allocation id changes otherwise identical shader source for every buffer.
520
+
521
+ } else {
522
+
@@ -3,31 +3,38 @@ import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  // Installing ThreeNative wires its MCP servers into the project. An agent authoring a game here
5
5
  // should not have to be told the asset, sculpt and capability tools exist, or run a setup command
6
- // to get them. `.mcp.json` serves compatible hosts; Codex reads project-scoped `.codex/config.toml`.
7
- // Set THREENATIVE_SKIP_MCP_SETUP=1 to opt out.
8
- import { ensureCodexMcpConfig, ensureMcpConfig, installTarget } from "../mcp/install.mjs";
6
+ // to get them. Every host that reads a project-scoped MCP config gets one — `MCP_HOSTS` is the
7
+ // list. Set THREENATIVE_SKIP_MCP_SETUP=1 to opt out.
8
+ import { MCP_HOSTS, ensureHostMcpConfigs, installTarget } from "../mcp/install.mjs";
9
+
10
+ const LABELS = new Map(MCP_HOSTS.map((host) => [host.id, host.label]));
9
11
 
10
12
  export function runMcpSetup(environment = process.env, cwd = process.cwd()) {
11
13
  const skip = environment.THREENATIVE_SKIP_MCP_SETUP;
12
14
  if (skip === "1" || skip === "true") return;
13
- // Never fail an install over this. A game that cannot write .mcp.json still runs.
15
+ // Never fail an install over this. A game that cannot write its MCP configs still runs.
14
16
  try {
15
17
  const target = installTarget(environment, cwd);
16
18
  if (target === undefined) return;
17
- const outcome = ensureMcpConfig(target);
18
- const codexOutcome = ensureCodexMcpConfig(target);
19
- if (outcome === "created" || outcome === "updated") {
20
- process.stdout.write(`threenative: ${outcome} .mcp.json with the ThreeNative MCP servers\n`);
21
- } else if (outcome === "unreadable") {
22
- process.stdout.write("threenative: .mcp.json is not valid JSON; left it alone\n");
19
+ const outcomes = ensureHostMcpConfigs(target);
20
+ const wired = [...outcomes]
21
+ .filter(([, outcome]) => outcome === "created" || outcome === "updated")
22
+ .map(([id]) => LABELS.get(id) ?? id);
23
+ if (wired.length > 0) {
24
+ process.stdout.write(
25
+ `threenative: wired the ThreeNative MCP servers for ${wired.join(", ")}\n`,
26
+ );
23
27
  }
24
- if (codexOutcome === "created" || codexOutcome === "updated") {
28
+ const unreadable = [...outcomes]
29
+ .filter(([, outcome]) => outcome === "unreadable")
30
+ .map(([id]) => LABELS.get(id) ?? id);
31
+ if (unreadable.length > 0) {
25
32
  process.stdout.write(
26
- `threenative: ${codexOutcome} .codex/config.toml with the ThreeNative MCP servers\n`,
33
+ `threenative: left ${unreadable.join(", ")} alone; its config is not valid JSON\n`,
27
34
  );
28
35
  }
29
36
  } catch (error) {
30
- process.stdout.write(`threenative: could not wire .mcp.json (${error})\n`);
37
+ process.stdout.write(`threenative: could not wire the MCP configs (${error})\n`);
31
38
  }
32
39
  }
33
40
 
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env node
2
- import { copyFileSync, existsSync } from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const coreRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
7
- const source = path.resolve(coreRoot, "..", "engine-mcp", "dist", "index.js");
8
- const target = path.join(coreRoot, "mcp", "engine-server.mjs");
9
-
10
- if (!existsSync(source)) {
11
- throw new Error(
12
- `TN_ENGINE_MCP_BUNDLE: build threenative-engine-mcp before core; missing ${source}`,
13
- );
14
- }
15
- copyFileSync(source, target);
@@ -1,13 +0,0 @@
1
- // Single owner of the core version (PRD-181): package.json is the source of truth and
2
- // src/version.ts is generated from it and checked in. A hand-edited constant goes stale the
3
- // moment the manifest bumps; the pinning spec turns that red.
4
- import { readFile, writeFile } from "node:fs/promises";
5
-
6
- const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
7
- if (typeof manifest.version !== "string" || manifest.version.length === 0) {
8
- throw new Error("generate-version: package.json has no version");
9
- }
10
- await writeFile(
11
- new URL("../src/version.ts", import.meta.url),
12
- `// Generated by scripts/generate-version.mjs - do not edit. Source: package.json.\nexport const CORE_VERSION = ${JSON.stringify(manifest.version)};\n`,
13
- );