@three-flatland/normals 0.1.0-alpha.1 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,7 @@ flatland-bake normal <input.png> --descriptor <input.normal.json>
59
59
  | `--strength <n>` | Gradient multiplier before normalization (default `1`) |
60
60
  | `--descriptor <path>` | Region-aware descriptor JSON — per-frame / per-tile control for atlases and tilemaps |
61
61
 
62
- See the [three-flatland docs](https://thejustinwalsh.com/three-flatland/) for descriptor examples covering sprite sheets, LDtk tilesets, and directional (3/4-view wall) tiles.
62
+ See the [three-flatland docs](https://tjw.dev/three-flatland/) for descriptor examples covering sprite sheets, LDtk tilesets, and directional (3/4-view wall) tiles.
63
63
 
64
64
  ### Loader options
65
65
 
@@ -212,7 +212,7 @@ This package is renderer-agnostic and works with any Three.js WebGPU project. Ba
212
212
 
213
213
  ## Documentation
214
214
 
215
- Full docs, interactive examples, and API reference at **[thejustinwalsh.com/three-flatland](https://thejustinwalsh.com/three-flatland/)**
215
+ Full docs, interactive examples, and API reference at **[tjw.dev/three-flatland](https://tjw.dev/three-flatland/)**
216
216
 
217
217
  ## License
218
218
 
@@ -0,0 +1,99 @@
1
+ import { NormalSourceDescriptor } from "./descriptor.js";
2
+ import { Loader, LoadingManager, Texture } from "three";
3
+ import { BakedAssetLoaderOptions } from "@three-flatland/bake";
4
+ //#region src/NormalMapLoader.d.ts
5
+ /**
6
+ * Options accepted by `NormalMapLoader.load()`. Inherits `forceRuntime`
7
+ * from the shared {@link BakedAssetLoaderOptions} so every baked-asset
8
+ * loader in the codebase advertises the same option shape.
9
+ */
10
+ interface NormalMapLoaderStaticOptions extends BakedAssetLoaderOptions {
11
+ /**
12
+ * When provided, missing sidecars trigger an in-memory bake via
13
+ * `resolveNormalMap`. Without a descriptor, `NormalMapLoader.load()` can
14
+ * only probe the baked sibling and returns `null` on miss (legacy
15
+ * behavior, preserved for backward compat).
16
+ */
17
+ descriptor?: NormalSourceDescriptor;
18
+ }
19
+ /**
20
+ * Result of loading a sprite's normal data.
21
+ *
22
+ * Either a baked normal texture (fast path) or `null`, signalling the caller
23
+ * to use the runtime TSL `normalFromSprite` helper against the sprite's own
24
+ * alpha channel.
25
+ */
26
+ type NormalMapResult = Texture | null;
27
+ /**
28
+ * Loader for per-sprite normal maps following the canonical three-flatland
29
+ * "try baked → fall back to runtime" pattern documented in
30
+ * `planning/bake/loader-pattern.md`.
31
+ *
32
+ * Given a sprite PNG URL, this loader fetches the sibling `.normal.png` that
33
+ * `flatland-bake normal` produces. If it is absent, the loader resolves to
34
+ * `null` so the lit material can switch to its TSL runtime fallback.
35
+ *
36
+ * Only the baked path uses the network — the runtime branch is a shader
37
+ * concern and lives in `@three-flatland/nodes/lighting/normalFromSprite`.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * // Vanilla static API
42
+ * const normalTex = await NormalMapLoader.load('/sprites/knight.png')
43
+ * if (normalTex) material.normalMap = normalTex
44
+ * else material.useRuntimeNormals = true
45
+ *
46
+ * // Skip the baked probe — go straight to runtime (or null without descriptor)
47
+ * const tex = await NormalMapLoader.load(url, { forceRuntime: true })
48
+ *
49
+ * // R3F useLoader
50
+ * const tex = useLoader(NormalMapLoader, '/sprites/knight.png')
51
+ * ```
52
+ */
53
+ declare class NormalMapLoader extends Loader<NormalMapResult> {
54
+ /**
55
+ * Generate this asset's normal map in the browser on every load
56
+ * instead of loading a pre-baked `.normal.png` sidecar. With a
57
+ * `descriptor`, the in-memory bake runs on every load. Without a
58
+ * descriptor, the loader resolves directly to `null` and the caller
59
+ * uses the TSL runtime fallback. Suppresses the "no baked sibling"
60
+ * warn either way.
61
+ *
62
+ * Use when runtime is the right home for the bake. Not a dev-iteration
63
+ * knob — the default path (probe → bake on miss + warn) already
64
+ * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.
65
+ */
66
+ forceRuntime: boolean;
67
+ /**
68
+ * When set, missing sidecars trigger an in-memory bake via
69
+ * `resolveNormalMap`. Without a descriptor, the loader can only probe
70
+ * the baked sibling and returns `null` on miss (legacy behavior).
71
+ *
72
+ * Set via the `useLoader` callback:
73
+ * ```ts
74
+ * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })
75
+ * ```
76
+ */
77
+ descriptor: NormalSourceDescriptor | undefined;
78
+ constructor(manager?: LoadingManager);
79
+ load(url: string, onLoad?: (data: NormalMapResult) => void, onProgress?: (event: ProgressEvent) => void, onError?: (err: unknown) => void): NormalMapResult;
80
+ loadAsync(url: string): Promise<NormalMapResult>;
81
+ /**
82
+ * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so
83
+ * two callers passing *different* descriptors for the same URL get
84
+ * distinct results instead of colliding on the first one's bake.
85
+ *
86
+ * Instance API (R3F `useLoader`) bypasses this map — R3F has its own
87
+ * suspense cache and we don't want double-caching to fight the lifecycle.
88
+ */
89
+ private static _cache;
90
+ static load(url: string, options?: NormalMapLoaderStaticOptions): Promise<NormalMapResult>;
91
+ static clearCache(): void;
92
+ private static _loadImpl;
93
+ private static _tryLoadBaked;
94
+ }
95
+ /** Clear the devtime-warning dedupe set. Intended for tests. */
96
+ declare function _resetDevtimeWarnings(): void;
97
+ //#endregion
98
+ export { NormalMapLoader, NormalMapLoaderStaticOptions, NormalMapResult, _resetDevtimeWarnings };
99
+ //# sourceMappingURL=NormalMapLoader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NormalMapLoader.d.ts","names":[],"sources":["../src/NormalMapLoader.ts"],"mappings":";;;;;;;;;UAeiB,qCAAqC;;;;;;;EAOpD,aAAa;;;;;;;;;KAUH,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;cA4BjB,wBAAwB,OAAO;;;;;;;;;;;;;EAa1C;;;;;;;;;;;EAWA,YAAY;EAEA,YAAA,UAAU;EAMtB,KACE,aACA,UAAU,MAAM,0BAChB,cAAc,OAAO,wBACrB,WAAW,wBACV;EAgBH,UAAU,cAAc,QAAQ;;;;;;;;;iBAcjB;SAER,KAAK,aAAa,UAAU,+BAA+B,QAAQ;SAgBnE;iBAMc;iBA4BA;;;iBA6BP"}
@@ -1,129 +1,132 @@
1
- import { Loader, TextureLoader } from "three";
2
- import {
3
- devtimeWarn as sharedDevtimeWarn,
4
- _resetDevtimeWarnings as sharedResetDevtimeWarnings,
5
- hashDescriptor
6
- } from "@three-flatland/bake";
7
1
  import { bakedNormalURL } from "./bake.js";
8
- class NormalMapLoader extends Loader {
9
- /**
10
- * Generate this asset's normal map in the browser on every load
11
- * instead of loading a pre-baked `.normal.png` sidecar. With a
12
- * `descriptor`, the in-memory bake runs on every load. Without a
13
- * descriptor, the loader resolves directly to `null` and the caller
14
- * uses the TSL runtime fallback. Suppresses the "no baked sibling"
15
- * warn either way.
16
- *
17
- * Use when runtime is the right home for the bake. Not a dev-iteration
18
- * knob the default path (probe bake on miss + warn) already
19
- * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.
20
- */
21
- forceRuntime = false;
22
- /**
23
- * When set, missing sidecars trigger an in-memory bake via
24
- * `resolveNormalMap`. Without a descriptor, the loader can only probe
25
- * the baked sibling and returns `null` on miss (legacy behavior).
26
- *
27
- * Set via the `useLoader` callback:
28
- * ```ts
29
- * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })
30
- * ```
31
- */
32
- descriptor = void 0;
33
- constructor(manager) {
34
- super(manager);
35
- }
36
- // ─── Instance API (R3F useLoader compatibility) ───
37
- load(url, onLoad, onProgress, onError) {
38
- const resolved = this.manager.resolveURL(url);
39
- NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor).then((result) => {
40
- onLoad?.(result);
41
- }).catch((err) => {
42
- if (onError) onError(err);
43
- else console.error("NormalMapLoader:", err);
44
- this.manager.itemError(url);
45
- });
46
- return null;
47
- }
48
- loadAsync(url) {
49
- return NormalMapLoader._loadImpl(
50
- this.manager.resolveURL(url),
51
- this.forceRuntime,
52
- this.descriptor
53
- );
54
- }
55
- // ─── Static API (vanilla usage) ───
56
- /**
57
- * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so
58
- * two callers passing *different* descriptors for the same URL get
59
- * distinct results instead of colliding on the first one's bake.
60
- *
61
- * Instance API (R3F `useLoader`) bypasses this map — R3F has its own
62
- * suspense cache and we don't want double-caching to fight the lifecycle.
63
- */
64
- static _cache = /* @__PURE__ */ new Map();
65
- static load(url, options) {
66
- const forceRuntime = options?.forceRuntime ?? false;
67
- const descriptor = options?.descriptor;
68
- const descriptorKey = descriptor ? hashDescriptor(descriptor) : "nodesc";
69
- const cacheKey = `${url}:${forceRuntime ? "runtime" : "probe"}:${descriptorKey}`;
70
- const cached = this._cache.get(cacheKey);
71
- if (cached) return cached;
72
- const promise = this._loadImpl(url, forceRuntime, descriptor);
73
- this._cache.set(cacheKey, promise);
74
- return promise;
75
- }
76
- static clearCache() {
77
- this._cache.clear();
78
- }
79
- // ─── Implementation ───
80
- static async _loadImpl(url, forceRuntime, descriptor) {
81
- if (descriptor) {
82
- const { resolveNormalMap } = await import("./resolveNormalMap.js");
83
- return resolveNormalMap(url, descriptor, { forceRuntime });
84
- }
85
- if (!forceRuntime) {
86
- const baked = await this._tryLoadBaked(url);
87
- if (baked) return baked;
88
- }
89
- sharedDevtimeWarn(
90
- "normal",
91
- url,
92
- `No baked normal sibling for ${url} and no descriptor passed. Either pre-bake (\`npx flatland-bake normal\`), pass a \`descriptor\` to NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader with \`normals: true\` (which synthesizes a descriptor for you).`
93
- );
94
- return null;
95
- }
96
- static async _tryLoadBaked(spriteURL) {
97
- const bakedURL = bakedNormalURL(spriteURL);
98
- let head;
99
- try {
100
- head = await fetch(bakedURL, { method: "HEAD" });
101
- } catch {
102
- return null;
103
- }
104
- if (!head.ok) return null;
105
- return new Promise((resolve, _reject) => {
106
- const loader = new TextureLoader();
107
- loader.load(
108
- bakedURL,
109
- (tex) => resolve(tex),
110
- void 0,
111
- (err) => {
112
- console.warn(
113
- `[normal] Found ${bakedURL} via HEAD but TextureLoader failed \u2014 falling back to runtime.`,
114
- err
115
- );
116
- resolve(null);
117
- }
118
- );
119
- });
120
- }
121
- }
2
+ import { Loader, TextureLoader } from "three";
3
+ import { _resetDevtimeWarnings as _resetDevtimeWarnings$1, devtimeWarn, hashDescriptor } from "@three-flatland/bake";
4
+ //#region src/NormalMapLoader.ts
5
+ /**
6
+ * Loader for per-sprite normal maps following the canonical three-flatland
7
+ * "try baked fall back to runtime" pattern documented in
8
+ * `planning/bake/loader-pattern.md`.
9
+ *
10
+ * Given a sprite PNG URL, this loader fetches the sibling `.normal.png` that
11
+ * `flatland-bake normal` produces. If it is absent, the loader resolves to
12
+ * `null` so the lit material can switch to its TSL runtime fallback.
13
+ *
14
+ * Only the baked path uses the network — the runtime branch is a shader
15
+ * concern and lives in `@three-flatland/nodes/lighting/normalFromSprite`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * // Vanilla static API
20
+ * const normalTex = await NormalMapLoader.load('/sprites/knight.png')
21
+ * if (normalTex) material.normalMap = normalTex
22
+ * else material.useRuntimeNormals = true
23
+ *
24
+ * // Skip the baked probe — go straight to runtime (or null without descriptor)
25
+ * const tex = await NormalMapLoader.load(url, { forceRuntime: true })
26
+ *
27
+ * // R3F useLoader
28
+ * const tex = useLoader(NormalMapLoader, '/sprites/knight.png')
29
+ * ```
30
+ */
31
+ var NormalMapLoader = class NormalMapLoader extends Loader {
32
+ /**
33
+ * Generate this asset's normal map in the browser on every load
34
+ * instead of loading a pre-baked `.normal.png` sidecar. With a
35
+ * `descriptor`, the in-memory bake runs on every load. Without a
36
+ * descriptor, the loader resolves directly to `null` and the caller
37
+ * uses the TSL runtime fallback. Suppresses the "no baked sibling"
38
+ * warn either way.
39
+ *
40
+ * Use when runtime is the right home for the bake. Not a dev-iteration
41
+ * knob — the default path (probe → bake on miss + warn) already
42
+ * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.
43
+ */
44
+ forceRuntime = false;
45
+ /**
46
+ * When set, missing sidecars trigger an in-memory bake via
47
+ * `resolveNormalMap`. Without a descriptor, the loader can only probe
48
+ * the baked sibling and returns `null` on miss (legacy behavior).
49
+ *
50
+ * Set via the `useLoader` callback:
51
+ * ```ts
52
+ * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })
53
+ * ```
54
+ */
55
+ descriptor = void 0;
56
+ constructor(manager) {
57
+ super(manager);
58
+ }
59
+ load(url, onLoad, onProgress, onError) {
60
+ const resolved = this.manager.resolveURL(url);
61
+ NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor).then((result) => {
62
+ onLoad?.(result);
63
+ }).catch((err) => {
64
+ if (onError) onError(err);
65
+ else console.error("NormalMapLoader:", err);
66
+ this.manager.itemError(url);
67
+ });
68
+ return null;
69
+ }
70
+ loadAsync(url) {
71
+ return NormalMapLoader._loadImpl(this.manager.resolveURL(url), this.forceRuntime, this.descriptor);
72
+ }
73
+ /**
74
+ * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so
75
+ * two callers passing *different* descriptors for the same URL get
76
+ * distinct results instead of colliding on the first one's bake.
77
+ *
78
+ * Instance API (R3F `useLoader`) bypasses this map — R3F has its own
79
+ * suspense cache and we don't want double-caching to fight the lifecycle.
80
+ */
81
+ static _cache = /* @__PURE__ */ new Map();
82
+ static load(url, options) {
83
+ const forceRuntime = options?.forceRuntime ?? false;
84
+ const descriptor = options?.descriptor;
85
+ const descriptorKey = descriptor ? hashDescriptor(descriptor) : "nodesc";
86
+ const cacheKey = `${url}:${forceRuntime ? "runtime" : "probe"}:${descriptorKey}`;
87
+ const cached = this._cache.get(cacheKey);
88
+ if (cached) return cached;
89
+ const promise = this._loadImpl(url, forceRuntime, descriptor);
90
+ this._cache.set(cacheKey, promise);
91
+ return promise;
92
+ }
93
+ static clearCache() {
94
+ this._cache.clear();
95
+ }
96
+ static async _loadImpl(url, forceRuntime, descriptor) {
97
+ if (descriptor) {
98
+ const { resolveNormalMap } = await import("./resolveNormalMap.js");
99
+ return resolveNormalMap(url, descriptor, { forceRuntime });
100
+ }
101
+ if (!forceRuntime) {
102
+ const baked = await this._tryLoadBaked(url);
103
+ if (baked) return baked;
104
+ }
105
+ devtimeWarn("normal", url, `No baked normal sibling for ${url} and no descriptor passed. Either pre-bake (\`npx flatland-bake normal\`), pass a \`descriptor\` to NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader with \`normals: true\` (which synthesizes a descriptor for you).`);
106
+ return null;
107
+ }
108
+ static async _tryLoadBaked(spriteURL) {
109
+ const bakedURL = bakedNormalURL(spriteURL);
110
+ let head;
111
+ try {
112
+ head = await fetch(bakedURL, { method: "HEAD" });
113
+ } catch {
114
+ return null;
115
+ }
116
+ if (!head.ok) return null;
117
+ return new Promise((resolve, _reject) => {
118
+ new TextureLoader().load(bakedURL, (tex) => resolve(tex), void 0, (err) => {
119
+ console.warn(`[normal] Found ${bakedURL} via HEAD but TextureLoader failed — falling back to runtime.`, err);
120
+ resolve(null);
121
+ });
122
+ });
123
+ }
124
+ };
125
+ /** Clear the devtime-warning dedupe set. Intended for tests. */
122
126
  function _resetDevtimeWarnings() {
123
- sharedResetDevtimeWarnings();
127
+ _resetDevtimeWarnings$1();
124
128
  }
125
- export {
126
- NormalMapLoader,
127
- _resetDevtimeWarnings
128
- };
129
+ //#endregion
130
+ export { NormalMapLoader, _resetDevtimeWarnings };
131
+
129
132
  //# sourceMappingURL=NormalMapLoader.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/NormalMapLoader.ts"],"sourcesContent":["import { Loader, type LoadingManager, TextureLoader, type Texture } from 'three'\nimport {\n devtimeWarn as sharedDevtimeWarn,\n _resetDevtimeWarnings as sharedResetDevtimeWarnings,\n hashDescriptor,\n type BakedAssetLoaderOptions,\n} from '@three-flatland/bake'\nimport { bakedNormalURL } from './bake.js'\nimport type { NormalSourceDescriptor } from './descriptor.js'\n\n/**\n * Options accepted by `NormalMapLoader.load()`. Inherits `forceRuntime`\n * from the shared {@link BakedAssetLoaderOptions} so every baked-asset\n * loader in the codebase advertises the same option shape.\n */\nexport interface NormalMapLoaderStaticOptions extends BakedAssetLoaderOptions {\n /**\n * When provided, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, `NormalMapLoader.load()` can\n * only probe the baked sibling and returns `null` on miss (legacy\n * behavior, preserved for backward compat).\n */\n descriptor?: NormalSourceDescriptor\n}\n\n/**\n * Result of loading a sprite's normal data.\n *\n * Either a baked normal texture (fast path) or `null`, signalling the caller\n * to use the runtime TSL `normalFromSprite` helper against the sprite's own\n * alpha channel.\n */\nexport type NormalMapResult = Texture | null\n\n/**\n * Loader for per-sprite normal maps following the canonical three-flatland\n * \"try baked → fall back to runtime\" pattern documented in\n * `planning/bake/loader-pattern.md`.\n *\n * Given a sprite PNG URL, this loader fetches the sibling `.normal.png` that\n * `flatland-bake normal` produces. If it is absent, the loader resolves to\n * `null` so the lit material can switch to its TSL runtime fallback.\n *\n * Only the baked path uses the network — the runtime branch is a shader\n * concern and lives in `@three-flatland/nodes/lighting/normalFromSprite`.\n *\n * @example\n * ```ts\n * // Vanilla static API\n * const normalTex = await NormalMapLoader.load('/sprites/knight.png')\n * if (normalTex) material.normalMap = normalTex\n * else material.useRuntimeNormals = true\n *\n * // Skip the baked probe — go straight to runtime (or null without descriptor)\n * const tex = await NormalMapLoader.load(url, { forceRuntime: true })\n *\n * // R3F useLoader\n * const tex = useLoader(NormalMapLoader, '/sprites/knight.png')\n * ```\n */\nexport class NormalMapLoader extends Loader<NormalMapResult> {\n /**\n * Generate this asset's normal map in the browser on every load\n * instead of loading a pre-baked `.normal.png` sidecar. With a\n * `descriptor`, the in-memory bake runs on every load. Without a\n * descriptor, the loader resolves directly to `null` and the caller\n * uses the TSL runtime fallback. Suppresses the \"no baked sibling\"\n * warn either way.\n *\n * Use when runtime is the right home for the bake. Not a dev-iteration\n * knob — the default path (probe → bake on miss + warn) already\n * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.\n */\n forceRuntime = false\n /**\n * When set, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, the loader can only probe\n * the baked sibling and returns `null` on miss (legacy behavior).\n *\n * Set via the `useLoader` callback:\n * ```ts\n * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })\n * ```\n */\n descriptor: NormalSourceDescriptor | undefined = undefined\n\n constructor(manager?: LoadingManager) {\n super(manager)\n }\n\n // ─── Instance API (R3F useLoader compatibility) ───\n\n load(\n url: string,\n onLoad?: (data: NormalMapResult) => void,\n onProgress?: (event: ProgressEvent) => void,\n onError?: (err: unknown) => void\n ): NormalMapResult {\n const resolved = this.manager.resolveURL(url)\n\n NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor)\n .then((result) => {\n onLoad?.(result)\n })\n .catch((err) => {\n if (onError) onError(err)\n else console.error('NormalMapLoader:', err)\n this.manager.itemError(url)\n })\n\n return null\n }\n\n loadAsync(url: string): Promise<NormalMapResult> {\n return NormalMapLoader._loadImpl(\n this.manager.resolveURL(url),\n this.forceRuntime,\n this.descriptor\n )\n }\n\n // ─── Static API (vanilla usage) ───\n\n /**\n * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so\n * two callers passing *different* descriptors for the same URL get\n * distinct results instead of colliding on the first one's bake.\n *\n * Instance API (R3F `useLoader`) bypasses this map — R3F has its own\n * suspense cache and we don't want double-caching to fight the lifecycle.\n */\n private static _cache = new Map<string, Promise<NormalMapResult>>()\n\n static load(\n url: string,\n options?: NormalMapLoaderStaticOptions\n ): Promise<NormalMapResult> {\n const forceRuntime = options?.forceRuntime ?? false\n const descriptor = options?.descriptor\n // Hash the descriptor so distinct descriptors for the same URL get\n // distinct cache entries. `descriptor ? 'desc' : 'nodesc'` would have\n // collapsed every (URL, *) pair into one slot — first-bake-wins.\n const descriptorKey = descriptor ? hashDescriptor(descriptor) : 'nodesc'\n const cacheKey = `${url}:${forceRuntime ? 'runtime' : 'probe'}:${descriptorKey}`\n const cached = this._cache.get(cacheKey)\n if (cached) return cached\n\n const promise = this._loadImpl(url, forceRuntime, descriptor)\n this._cache.set(cacheKey, promise)\n return promise\n }\n\n static clearCache(): void {\n this._cache.clear()\n }\n\n // ─── Implementation ───\n\n private static async _loadImpl(\n url: string,\n forceRuntime: boolean,\n descriptor: NormalSourceDescriptor | undefined\n ): Promise<NormalMapResult> {\n // With a descriptor we can do the full resolve: try baked → in-memory bake.\n if (descriptor) {\n const { resolveNormalMap } = await import('./resolveNormalMap.js')\n return resolveNormalMap(url, descriptor, { forceRuntime })\n }\n\n // No descriptor → legacy URL-only behavior: probe sidecar, return null on miss.\n if (!forceRuntime) {\n const baked = await this._tryLoadBaked(url)\n if (baked) return baked\n }\n\n sharedDevtimeWarn(\n 'normal',\n url,\n `No baked normal sibling for ${url} and no descriptor passed. ` +\n `Either pre-bake (\\`npx flatland-bake normal\\`), pass a \\`descriptor\\` to ` +\n `NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader ` +\n `with \\`normals: true\\` (which synthesizes a descriptor for you).`\n )\n return null\n }\n\n private static async _tryLoadBaked(spriteURL: string): Promise<Texture | null> {\n const bakedURL = bakedNormalURL(spriteURL)\n\n // Probe with HEAD first so a 404 stays silent. TextureLoader swallows\n // network errors into a generic event, which is not what we want.\n let head: Response\n try {\n head = await fetch(bakedURL, { method: 'HEAD' })\n } catch {\n return null\n }\n if (!head.ok) return null\n\n return new Promise((resolve, _reject) => {\n const loader = new TextureLoader()\n loader.load(\n bakedURL,\n (tex) => resolve(tex as unknown as Texture),\n undefined,\n (err) => {\n console.warn(\n `[normal] Found ${bakedURL} via HEAD but TextureLoader failed — falling back to runtime.`,\n err\n )\n resolve(null)\n }\n )\n })\n }\n}\n\n/** Clear the devtime-warning dedupe set. Intended for tests. */\nexport function _resetDevtimeWarnings(): void {\n sharedResetDevtimeWarnings()\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"file":"NormalMapLoader.js","names":[],"sources":["../src/NormalMapLoader.ts"],"sourcesContent":["import { Loader, type LoadingManager, TextureLoader, type Texture } from 'three'\nimport {\n devtimeWarn as sharedDevtimeWarn,\n _resetDevtimeWarnings as sharedResetDevtimeWarnings,\n hashDescriptor,\n type BakedAssetLoaderOptions,\n} from '@three-flatland/bake'\nimport { bakedNormalURL } from './bake.js'\nimport type { NormalSourceDescriptor } from './descriptor.js'\n\n/**\n * Options accepted by `NormalMapLoader.load()`. Inherits `forceRuntime`\n * from the shared {@link BakedAssetLoaderOptions} so every baked-asset\n * loader in the codebase advertises the same option shape.\n */\nexport interface NormalMapLoaderStaticOptions extends BakedAssetLoaderOptions {\n /**\n * When provided, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, `NormalMapLoader.load()` can\n * only probe the baked sibling and returns `null` on miss (legacy\n * behavior, preserved for backward compat).\n */\n descriptor?: NormalSourceDescriptor\n}\n\n/**\n * Result of loading a sprite's normal data.\n *\n * Either a baked normal texture (fast path) or `null`, signalling the caller\n * to use the runtime TSL `normalFromSprite` helper against the sprite's own\n * alpha channel.\n */\nexport type NormalMapResult = Texture | null\n\n/**\n * Loader for per-sprite normal maps following the canonical three-flatland\n * \"try baked → fall back to runtime\" pattern documented in\n * `planning/bake/loader-pattern.md`.\n *\n * Given a sprite PNG URL, this loader fetches the sibling `.normal.png` that\n * `flatland-bake normal` produces. If it is absent, the loader resolves to\n * `null` so the lit material can switch to its TSL runtime fallback.\n *\n * Only the baked path uses the network — the runtime branch is a shader\n * concern and lives in `@three-flatland/nodes/lighting/normalFromSprite`.\n *\n * @example\n * ```ts\n * // Vanilla static API\n * const normalTex = await NormalMapLoader.load('/sprites/knight.png')\n * if (normalTex) material.normalMap = normalTex\n * else material.useRuntimeNormals = true\n *\n * // Skip the baked probe — go straight to runtime (or null without descriptor)\n * const tex = await NormalMapLoader.load(url, { forceRuntime: true })\n *\n * // R3F useLoader\n * const tex = useLoader(NormalMapLoader, '/sprites/knight.png')\n * ```\n */\nexport class NormalMapLoader extends Loader<NormalMapResult> {\n /**\n * Generate this asset's normal map in the browser on every load\n * instead of loading a pre-baked `.normal.png` sidecar. With a\n * `descriptor`, the in-memory bake runs on every load. Without a\n * descriptor, the loader resolves directly to `null` and the caller\n * uses the TSL runtime fallback. Suppresses the \"no baked sibling\"\n * warn either way.\n *\n * Use when runtime is the right home for the bake. Not a dev-iteration\n * knob — the default path (probe → bake on miss + warn) already\n * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.\n */\n forceRuntime = false\n /**\n * When set, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, the loader can only probe\n * the baked sibling and returns `null` on miss (legacy behavior).\n *\n * Set via the `useLoader` callback:\n * ```ts\n * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })\n * ```\n */\n descriptor: NormalSourceDescriptor | undefined = undefined\n\n constructor(manager?: LoadingManager) {\n super(manager)\n }\n\n // ─── Instance API (R3F useLoader compatibility) ───\n\n load(\n url: string,\n onLoad?: (data: NormalMapResult) => void,\n onProgress?: (event: ProgressEvent) => void,\n onError?: (err: unknown) => void\n ): NormalMapResult {\n const resolved = this.manager.resolveURL(url)\n\n NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor)\n .then((result) => {\n onLoad?.(result)\n })\n .catch((err) => {\n if (onError) onError(err)\n else console.error('NormalMapLoader:', err)\n this.manager.itemError(url)\n })\n\n return null\n }\n\n loadAsync(url: string): Promise<NormalMapResult> {\n return NormalMapLoader._loadImpl(this.manager.resolveURL(url), this.forceRuntime, this.descriptor)\n }\n\n // ─── Static API (vanilla usage) ───\n\n /**\n * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so\n * two callers passing *different* descriptors for the same URL get\n * distinct results instead of colliding on the first one's bake.\n *\n * Instance API (R3F `useLoader`) bypasses this map — R3F has its own\n * suspense cache and we don't want double-caching to fight the lifecycle.\n */\n private static _cache = new Map<string, Promise<NormalMapResult>>()\n\n static load(url: string, options?: NormalMapLoaderStaticOptions): Promise<NormalMapResult> {\n const forceRuntime = options?.forceRuntime ?? false\n const descriptor = options?.descriptor\n // Hash the descriptor so distinct descriptors for the same URL get\n // distinct cache entries. `descriptor ? 'desc' : 'nodesc'` would have\n // collapsed every (URL, *) pair into one slot — first-bake-wins.\n const descriptorKey = descriptor ? hashDescriptor(descriptor) : 'nodesc'\n const cacheKey = `${url}:${forceRuntime ? 'runtime' : 'probe'}:${descriptorKey}`\n const cached = this._cache.get(cacheKey)\n if (cached) return cached\n\n const promise = this._loadImpl(url, forceRuntime, descriptor)\n this._cache.set(cacheKey, promise)\n return promise\n }\n\n static clearCache(): void {\n this._cache.clear()\n }\n\n // ─── Implementation ───\n\n private static async _loadImpl(\n url: string,\n forceRuntime: boolean,\n descriptor: NormalSourceDescriptor | undefined\n ): Promise<NormalMapResult> {\n // With a descriptor we can do the full resolve: try baked → in-memory bake.\n if (descriptor) {\n const { resolveNormalMap } = await import('./resolveNormalMap.js')\n return resolveNormalMap(url, descriptor, { forceRuntime })\n }\n\n // No descriptor → legacy URL-only behavior: probe sidecar, return null on miss.\n if (!forceRuntime) {\n const baked = await this._tryLoadBaked(url)\n if (baked) return baked\n }\n\n sharedDevtimeWarn(\n 'normal',\n url,\n `No baked normal sibling for ${url} and no descriptor passed. ` +\n `Either pre-bake (\\`npx flatland-bake normal\\`), pass a \\`descriptor\\` to ` +\n `NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader ` +\n `with \\`normals: true\\` (which synthesizes a descriptor for you).`\n )\n return null\n }\n\n private static async _tryLoadBaked(spriteURL: string): Promise<Texture | null> {\n const bakedURL = bakedNormalURL(spriteURL)\n\n // Probe with HEAD first so a 404 stays silent. TextureLoader swallows\n // network errors into a generic event, which is not what we want.\n let head: Response\n try {\n head = await fetch(bakedURL, { method: 'HEAD' })\n } catch {\n return null\n }\n if (!head.ok) return null\n\n return new Promise((resolve, _reject) => {\n const loader = new TextureLoader()\n loader.load(\n bakedURL,\n (tex) => resolve(tex as unknown as Texture),\n undefined,\n (err) => {\n console.warn(`[normal] Found ${bakedURL} via HEAD but TextureLoader failed — falling back to runtime.`, err)\n resolve(null)\n }\n )\n })\n }\n}\n\n/** Clear the devtime-warning dedupe set. Intended for tests. */\nexport function _resetDevtimeWarnings(): void {\n sharedResetDevtimeWarnings()\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DA,IAAa,kBAAb,MAAa,wBAAwB,OAAwB;;;;;;;;;;;;;CAa3D,eAAe;;;;;;;;;;;CAWf,aAAiD,KAAA;CAEjD,YAAY,SAA0B;EACpC,MAAM,OAAO;CACf;CAIA,KACE,KACA,QACA,YACA,SACiB;EACjB,MAAM,WAAW,KAAK,QAAQ,WAAW,GAAG;EAE5C,gBAAgB,UAAU,UAAU,KAAK,cAAc,KAAK,UAAU,CAAC,CACpE,MAAM,WAAW;GAChB,SAAS,MAAM;EACjB,CAAC,CAAC,CACD,OAAO,QAAQ;GACd,IAAI,SAAS,QAAQ,GAAG;QACnB,QAAQ,MAAM,oBAAoB,GAAG;GAC1C,KAAK,QAAQ,UAAU,GAAG;EAC5B,CAAC;EAEH,OAAO;CACT;CAEA,UAAU,KAAuC;EAC/C,OAAO,gBAAgB,UAAU,KAAK,QAAQ,WAAW,GAAG,GAAG,KAAK,cAAc,KAAK,UAAU;CACnG;;;;;;;;;CAYA,OAAe,yBAAS,IAAI,IAAsC;CAElE,OAAO,KAAK,KAAa,SAAkE;EACzF,MAAM,eAAe,SAAS,gBAAgB;EAC9C,MAAM,aAAa,SAAS;EAI5B,MAAM,gBAAgB,aAAa,eAAe,UAAU,IAAI;EAChE,MAAM,WAAW,GAAG,IAAI,GAAG,eAAe,YAAY,QAAQ,GAAG;EACjE,MAAM,SAAS,KAAK,OAAO,IAAI,QAAQ;EACvC,IAAI,QAAQ,OAAO;EAEnB,MAAM,UAAU,KAAK,UAAU,KAAK,cAAc,UAAU;EAC5D,KAAK,OAAO,IAAI,UAAU,OAAO;EACjC,OAAO;CACT;CAEA,OAAO,aAAmB;EACxB,KAAK,OAAO,MAAM;CACpB;CAIA,aAAqB,UACnB,KACA,cACA,YAC0B;EAE1B,IAAI,YAAY;GACd,MAAM,EAAE,qBAAqB,MAAM,OAAO;GAC1C,OAAO,iBAAiB,KAAK,YAAY,EAAE,aAAa,CAAC;EAC3D;EAGA,IAAI,CAAC,cAAc;GACjB,MAAM,QAAQ,MAAM,KAAK,cAAc,GAAG;GAC1C,IAAI,OAAO,OAAO;EACpB;EAEA,YACE,UACA,KACA,+BAA+B,IAAI,oPAIrC;EACA,OAAO;CACT;CAEA,aAAqB,cAAc,WAA4C;EAC7E,MAAM,WAAW,eAAe,SAAS;EAIzC,IAAI;EACJ,IAAI;GACF,OAAO,MAAM,MAAM,UAAU,EAAE,QAAQ,OAAO,CAAC;EACjD,QAAQ;GACN,OAAO;EACT;EACA,IAAI,CAAC,KAAK,IAAI,OAAO;EAErB,OAAO,IAAI,SAAS,SAAS,YAAY;GAEvC,IADmB,cACd,CAAC,CAAC,KACL,WACC,QAAQ,QAAQ,GAAyB,GAC1C,KAAA,IACC,QAAQ;IACP,QAAQ,KAAK,kBAAkB,SAAS,gEAAgE,GAAG;IAC3G,QAAQ,IAAI;GACd,CACF;EACF,CAAC;CACH;AACF;;AAGA,SAAgB,wBAA8B;CAC5C,wBAA2B;AAC7B"}
package/dist/bake.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { NormalSourceDescriptor } from "./descriptor.js";
2
+ //#region src/bake.d.ts
3
+ /**
4
+ * @deprecated — legacy flat-texture bake options. Use
5
+ * `bakeNormalMap(pixels, w, h, descriptor)` for region-aware control.
6
+ */
7
+ interface BakeOptions {
8
+ /** Scales the alpha gradient before normalization. Default 1. */
9
+ strength?: number;
10
+ }
11
+ /**
12
+ * Produce a tangent-space normal map from source pixels + a descriptor.
13
+ *
14
+ * Cross-platform: browser + node. No filesystem, no pngjs. Used by
15
+ * the CLI baker (wrapped with file I/O) and the runtime loader
16
+ * (called directly on decoded image pixels).
17
+ *
18
+ * Per texel in each region:
19
+ * 1. `bump: 'alpha'` — central-difference gradient on the source
20
+ * alpha channel, clamped to the region bounds so adjacent regions
21
+ * (e.g. atlas cells) can't bleed into each other.
22
+ * 2. Compose with a tilt rotation that takes `+Z` to the region's
23
+ * `direction` at `pitch` radians.
24
+ * 3. Normalize and encode to `rgb = normal * 0.5 + 0.5`. Alpha is
25
+ * copied from the source so the baked map carries its own
26
+ * silhouette.
27
+ *
28
+ * Texels outside every region receive the flat normal `(0, 0, 1)`
29
+ * with the source's alpha — safe default for sparse descriptors.
30
+ *
31
+ * @param pixels RGBA pixel buffer, row-major, 4 bytes per pixel.
32
+ * @param width Pixel width.
33
+ * @param height Pixel height.
34
+ * @param descriptor Region / tilt / bump control. Defaults to a single
35
+ * whole-texture flat region with alpha-derived bump.
36
+ * @returns RGBA pixel buffer containing the encoded normal map.
37
+ */
38
+ declare function bakeNormalMap(pixels: Uint8Array, width: number, height: number, descriptor?: NormalSourceDescriptor): Uint8Array;
39
+ /**
40
+ * Legacy back-compat wrapper. Use `bakeNormalMap(pixels, w, h, {
41
+ * strength })` for equivalent behavior in new code.
42
+ *
43
+ * @deprecated
44
+ */
45
+ declare function bakeNormalMapFromPixels(pixels: Uint8Array, width: number, height: number, options?: BakeOptions): Uint8Array;
46
+ /**
47
+ * Derive the conventional `.normal.png` sibling URL for a sprite PNG.
48
+ *
49
+ * Runtime loaders call this to try the baked output before falling
50
+ * back to the runtime TSL path.
51
+ *
52
+ * Pure string rewrite — browser-safe, no filesystem.
53
+ */
54
+ declare function bakedNormalURL(spriteURL: string): string;
55
+ //#endregion
56
+ export { BakeOptions, bakeNormalMap, bakeNormalMapFromPixels, bakedNormalURL };
57
+ //# sourceMappingURL=bake.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bake.d.ts","names":[],"sources":["../src/bake.ts"],"mappings":";;;;;;UAaiB;;EAEf;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8Bc,cACd,QAAQ,YACR,eACA,gBACA,aAAY,yBACX;;;;;;;iBAmKa,wBACd,QAAQ,YACR,eACA,gBACA,UAAS,cACR;;;;;;;;;iBAiBa,eAAe"}