diff --git a/AGENTS.md b/AGENTS.md index 087e92ea..7b1416f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ The private Yarn workspace root contains the public library and its consumers. U - **Instance removal**: freed slots use zero-scale matrices until reused; bulk transforms skip them. Block removal and state changes release their model references without disposing shared resources. - **Instancing gate**: `addSceneObject` only dedupes when `asset.key.assetType === "models"`; `BlockObject`s are deliberately **not added to the scene graph** (their ModelObjects are) — block transforms only exist through instance references. - **`isX: true` marker + guard-function pattern** (`isMineRenderScene`, `isAssetKey`, …) is used instead of `instanceof` throughout. -- **The Env seam** (`src/Env.ts`) is the browser/Node boundary and the thing that keeps the build working. `EnvProvider` declares the four required platform-dependent capabilities — `createCanvas`, `createImage`, `imageSize`, `openCache` — plus the optional `createWorker("section")`, which only `BrowserEnv` implements (it returns `undefined` without `import.meta.url`, so CJS and IIFE outputs build sections synchronously); and `src/env/browser/` + `src/env/node/` implement them. **Nothing outside `src/env/node/` may import `canvas`, `gl`, `node-persist`, `image-size`, or a Node builtin as a value**; type-only imports (`import type`) are fine because they erase. The entries (`src/index.browser.ts`, `src/index.node.ts`) each `import "./env//register"` *first*, so the provider is installed before any other module body runs. Verify with: +- **The Env seam** (`src/Env.ts`) is the browser/Node boundary and the thing that keeps the build working. `EnvProvider` declares the four required platform-dependent capabilities — `createCanvas`, `createImage`, `imageSize`, `openCache` — plus the optional `createWorker("section")`, which only `BrowserEnv` implements (it loads `BrowserEnvOptions.sectionWorker` when `registerBrowserEnv` received one, otherwise the file beside `import.meta.url`; when that fails, including CJS and IIFE outputs without `import.meta.url`, it logs a warning and sections build synchronously); and `src/env/browser/` + `src/env/node/` implement them. **Nothing outside `src/env/node/` may import `canvas`, `gl`, `node-persist`, `image-size`, or a Node builtin as a value**; type-only imports (`import type`) are fine because they erase. The entries (`src/index.browser.ts`, `src/index.node.ts`) each `import "./env//register"` *first*, so the provider is installed before any other module body runs. Verify with: `rg --no-filename -o -e 'from ?"[^."][^"]*"' -e 'import\("[^."][^"]*"\)' packages/minerender/dist/browser/esm --glob '*.mjs' | sort -u` — that list of package imports, static and on first use, must stay free of Node modules. - **No work at import time.** Anything expensive or platform-dependent must be lazy (`Materials.MISSING_TEXTURE`, the `PERSISTENT_CACHE` getters, `Ticker`'s timers and `AssetLoader`'s default source are all deferred for this reason). A static field initializer that decodes an image or opens a cache will run before any consumer has configured anything, and in Node it will run in a process that has no DOM. - `Renderer`, `MineRenderScene`, `SceneObject`, and `SkinObject` must not statically import the model, block, entity, or GUI object modules, `UVMapper`, or `AssetLoader`. `test/Package.test.mjs` checks their built static import graph. diff --git a/packages/minerender/package.json b/packages/minerender/package.json index a877b3e2..661e59df 100644 --- a/packages/minerender/package.json +++ b/packages/minerender/package.json @@ -62,6 +62,7 @@ } }, "./bundle": "./dist/bundle.js", + "./section.worker": "./dist/browser/esm/env/browser/section.worker.mjs", "./package.json": "./package.json" }, "sideEffects": [ diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index 1806e38c..7845c110 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -28,7 +28,7 @@ Texture changes can also be asynchronous. Await `SkinObject.setSkinTexture` befo World edits such as `setBlockAt`, `placeMultiBlock`, and `clear` also return promises. Await them before reading the resulting rendered state. Bulk placement resolves each distinct block state once, yields to the event loop every few milliseconds, and refreshes neighbor culling once at the end; pass a `BatchedExecutor` to keep per-position batches instead. A failed bulk placement can leave successfully placed blocks in the world; it is not a transaction that rolls back earlier placements. -With `sectionMeshing: true`, changed sections rebuild after the placement promise resolves. The browser build prepares section geometry in a Web Worker when the bundle keeps `section.worker` beside the module; otherwise it builds synchronously. See [Browser and Node.js](./platforms.md#capability-boundaries). +With `sectionMeshing: true`, changed sections rebuild after the placement promise resolves. The browser build prepares section geometry in a Web Worker when the bundle keeps `section.worker` beside the module or `registerBrowserEnv({ sectionWorker })` names its URL; otherwise it builds synchronously. See [Browser and Node.js](./platforms.md#capability-boundaries). ## Versioned registry data diff --git a/packages/minerender/reference/platforms.md b/packages/minerender/reference/platforms.md index 5b604886..651b9eba 100644 --- a/packages/minerender/reference/platforms.md +++ b/packages/minerender/reference/platforms.md @@ -34,7 +34,13 @@ import { Renderer } from "minerender"; Rollup, which Vite uses for production builds, keeps every exported class in that chunk when the application uses `await import("minerender")`. -World section meshing starts its Web Worker with `new Worker(new URL("./section.worker.mjs", import.meta.url))`. Bundlers that recognize this pattern emit the worker file with your build; check that a `section.worker` file appears in the output. Without it, and in the CommonJS and script-tag builds, which have no `import.meta.url`, sections build synchronously on the main thread. +World section meshing starts its Web Worker with `new Worker(new URL("./section.worker.mjs", import.meta.url))`. Bundlers that recognize this pattern emit the worker file with your build; check that a `section.worker` file appears in the output. The CommonJS and script-tag builds have no `import.meta.url`, and some bundlers drop the file. There, copy `minerender/section.worker` (`dist/browser/esm/env/browser/section.worker.mjs`) to a path on the page's origin and pass its URL before you create a world: + +```ts +registerBrowserEnv({ sectionWorker: "/assets/section.worker.mjs" }); +``` + +Script-tag pages call `MineRender.registerBrowserEnv`. `sectionWorker` also accepts a function that returns a `Worker`. Without a worker, sections build synchronously on the main thread, and a failed worker start logs a warning. ## Platform requirements @@ -102,7 +108,7 @@ Use the environment that supports the operation: | ZIP resource packs | [BrowserArchiveProxy](/api/index/classes/BrowserArchiveProxy) | No Node-specific archive proxy is provided | | glTF and GLB export | Supported through [SceneExporter](/api/index/classes/SceneExporter) | Requires browser canvas and `FileReader` | | Video export | `Renderer.toVideo()` requires canvas `captureStream()` and `MediaRecorder`; formats depend on the browser | Requires a browser environment | -| World section meshing | Geometry builds in a Web Worker when the ESM build's `section.worker` file ships beside the module; the CommonJS and IIFE builds and bundlers that drop the file build synchronously | Synchronous | +| World section meshing | Geometry builds in a Web Worker loaded from beside the ESM module or from `registerBrowserEnv({ sectionWorker })`; otherwise it builds synchronously | Synchronous | `Renderer.toImage()` returns a data URL from its browser canvas. `NodeRenderer.renderToBuffer()` returns PNG bytes. DOM controls, displays, and glTF/GLB export remain browser-only; importing the Node build does not supply those browser APIs. diff --git a/packages/minerender/src/env/browser/BrowserEnv.ts b/packages/minerender/src/env/browser/BrowserEnv.ts index f2ace5ba..fd388618 100644 --- a/packages/minerender/src/env/browser/BrowserEnv.ts +++ b/packages/minerender/src/env/browser/BrowserEnv.ts @@ -1,9 +1,23 @@ import { Env, EnvProvider, ImageSizeInfo } from "../../Env"; import type { CompatCanvas, CompatImage } from "../../canvas/CanvasCompat"; import type { PersistentCache } from "../../cache/PersistentCache"; +import { logger } from "../../util/log"; import { BrowserCache } from "./BrowserCache"; import { probeImageSize } from "./probeImageSize"; +const log = logger("BrowserEnv"); + +/** Settings for {@link BrowserEnv} and {@link registerBrowserEnv}. */ +export interface BrowserEnvOptions { + /** + * Section worker script, as a URL, or a function that creates the worker. + * Set it when the bundle has no `import.meta.url`, such as the script-tag build: serve + * `minerender/section.worker` from the page's origin and pass its URL. + * Register before creating a world; the worker starts on the first section build. + */ + sectionWorker?: string | URL | (() => Worker); +} + /** A LocalForage that imports the library and opens the store on the first call. */ function lazyLocalForage(options: LocalForageOptions): LocalForage { let instance: Promise | undefined; @@ -28,6 +42,8 @@ export class BrowserEnv implements EnvProvider { public readonly name = "browser"; + constructor(public readonly options: BrowserEnvOptions = {}) {} + createCanvas(width: number, height: number): CompatCanvas { const canvas = document.createElement("canvas"); canvas.width = width; @@ -58,10 +74,13 @@ export class BrowserEnv implements EnvProvider { createWorker(name: "section"): Worker | undefined { if (name !== "section" || typeof Worker === "undefined") return undefined; try { + const sectionWorker = this.options.sectionWorker; + if (typeof sectionWorker === "function") return sectionWorker(); + if (sectionWorker !== undefined) return new Worker(sectionWorker, { type: "module" }); // Keep the literal URL so bundlers emit the worker file next to the entry. return new Worker(new URL("./section.worker.mjs", import.meta.url), { type: "module" }); - } catch { - // No import.meta.url outside ESM, or a cross-origin module worker. + } catch (error) { + log.warn("Could not start the section worker, so sections build on the main thread. Pass sectionWorker to registerBrowserEnv to load it from another URL.", error); return undefined; } } @@ -69,8 +88,8 @@ export class BrowserEnv implements EnvProvider { } /** Installs the browser provider. The browser package entry calls this automatically. */ -export function registerBrowserEnv(): BrowserEnv { - const env = new BrowserEnv(); +export function registerBrowserEnv(options?: BrowserEnvOptions): BrowserEnv { + const env = new BrowserEnv(options); Env.register(env); return env; } diff --git a/packages/minerender/src/index.browser.ts b/packages/minerender/src/index.browser.ts index c4a04376..8cfd8ae3 100644 --- a/packages/minerender/src/index.browser.ts +++ b/packages/minerender/src/index.browser.ts @@ -6,5 +6,6 @@ import "./env/browser/register"; export * from "./index"; export { BrowserEnv, registerBrowserEnv } from "./env/browser/BrowserEnv"; +export type { BrowserEnvOptions } from "./env/browser/BrowserEnv"; export { BrowserCache } from "./env/browser/BrowserCache"; export { probeImageSize } from "./env/browser/probeImageSize"; diff --git a/packages/minerender/src/world/SectionWorker.ts b/packages/minerender/src/world/SectionWorker.ts index ad92a8af..d54c935a 100644 --- a/packages/minerender/src/world/SectionWorker.ts +++ b/packages/minerender/src/world/SectionWorker.ts @@ -1,6 +1,9 @@ import { Env } from "../Env"; +import { logger } from "../util/log"; import type { SectionGeometryInput, SectionGeometryPage } from "./SectionGeometry"; +const log = logger("SectionWorker"); + /** Runs section geometry builds in the environment's shared worker. */ export class SectionWorker { private static initialized = false; @@ -24,8 +27,12 @@ export class SectionWorker { else pending.reject(new Error(reply.message)); }); // A failed worker or an undeliverable message leaves the pending builds unanswerable, so stop using it. - worker!.addEventListener("error", event => this.fail(event.message || "Section worker failed")); - worker!.addEventListener("messageerror", () => this.fail("Section worker message could not be deserialized")); + const fallBack = (message: string) => { + log.warn(`${message}, so sections build on the main thread`); + this.fail(message); + }; + worker!.addEventListener("error", event => fallBack(event.message || "Section worker failed")); + worker!.addEventListener("messageerror", () => fallBack("Section worker message could not be deserialized")); } /** The shared worker, or undefined when the environment has none or a previous worker failed. */ diff --git a/packages/minerender/test/BrowserEnv.test.ts b/packages/minerender/test/BrowserEnv.test.ts new file mode 100644 index 00000000..f8c77702 --- /dev/null +++ b/packages/minerender/test/BrowserEnv.test.ts @@ -0,0 +1,73 @@ +import test, { ExecutionContext } from "ava"; +import { Env } from "../src/Env"; +import { BrowserEnv, registerBrowserEnv } from "../src/env/browser/BrowserEnv"; +import { Logging, LogHandler } from "../src/util/log"; + +function fixture(t: ExecutionContext) { + const provider = Env["_provider"], worker = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + const messages: Parameters[] = []; + Logging.setHandler((...message) => { messages.push(message); }); + t.teardown(() => { + Env["_provider"] = provider; + if (worker) Object.defineProperty(globalThis, "Worker", worker); + else delete (globalThis as { Worker?: typeof Worker }).Worker; + Logging.setHandler(); + }); + return messages; +} + +for (const sectionWorker of ["/assets/section.worker.mjs", new URL("https://example.com/assets/section.worker.mjs")]) { + test.serial(`browser section workers accept a ${typeof sectionWorker === "string" ? "string" : "URL"} option`, t => { + const messages = fixture(t), calls: unknown[][] = []; + globalThis.Worker = class { + constructor(url: string | URL, options: WorkerOptions) { calls.push([url, options]); } + } as unknown as typeof Worker; + const worker = new BrowserEnv({ sectionWorker }).createWorker("section"); + t.true(worker instanceof Worker); + t.deepEqual(calls, [[sectionWorker, { type: "module" }]]); + t.deepEqual(messages, []); + }); +} + +test.serial("browser section workers accept a function without calling the Worker constructor", t => { + const messages = fixture(t), worker = {} as Worker; + let calls = 0; + globalThis.Worker = class { + constructor() { t.fail("Worker constructor should not be called"); } + } as unknown as typeof Worker; + const env = new BrowserEnv({ sectionWorker: () => { calls++; return worker; } }); + t.is(env.createWorker("section"), worker); + t.is(calls, 1); + t.deepEqual(messages, []); +}); + +test.serial("browser section workers warn when the Worker constructor throws", t => { + const messages = fixture(t), error = new Error("Worker blocked"); + globalThis.Worker = class { + constructor() { throw error; } + } as unknown as typeof Worker; + t.is(new BrowserEnv({ sectionWorker: "/assets/section.worker.mjs" }).createWorker("section"), undefined); + t.deepEqual(messages, [["warn", "BrowserEnv", [ + "Could not start the section worker, so sections build on the main thread. Pass sectionWorker to registerBrowserEnv to load it from another URL.", error + ]]]); +}); + +test.serial("browser section workers stay silent without a Worker global", t => { + const messages = fixture(t); + delete (globalThis as { Worker?: typeof Worker }).Worker; + const env = new BrowserEnv({ sectionWorker: () => { t.fail("worker function should not be called"); return {} as Worker; } }); + t.is(env.createWorker("section"), undefined); + t.deepEqual(messages, []); +}); + +test.serial("registerBrowserEnv installs a provider that uses the section worker option", t => { + const messages = fixture(t), calls: unknown[][] = [], sectionWorker = "/assets/section.worker.mjs"; + globalThis.Worker = class { + constructor(url: string | URL, options: WorkerOptions) { calls.push([url, options]); } + } as unknown as typeof Worker; + const env = registerBrowserEnv({ sectionWorker }); + t.is(Env.provider, env); + t.true(Env.provider.createWorker!("section") instanceof Worker); + t.deepEqual(calls, [[sectionWorker, { type: "module" }]]); + t.deepEqual(messages, []); +}); diff --git a/packages/minerender/test/Package.test.mjs b/packages/minerender/test/Package.test.mjs index 2d6e0a35..3d94aaf2 100644 --- a/packages/minerender/test/Package.test.mjs +++ b/packages/minerender/test/Package.test.mjs @@ -148,6 +148,12 @@ test("renderer and skin static imports exclude non-skin modules and dependencies } }); +test("the section worker package export resolves to the built worker file", t => { + const worker = import.meta.resolve("minerender/section.worker"); + t.is(worker, new URL("../dist/browser/esm/env/browser/section.worker.mjs", import.meta.url).href); + t.true(existsSync(new URL(worker))); +}); + for (const [format, library] of [["ESM", esm], ["CJS", cjs]]) { test(`${format} package exports geometry without browser globals`, t => { const mesh = new Mesh(new BoxGeometry(), new MeshBasicMaterial()); diff --git a/packages/minerender/test/SectionMesh.test.ts b/packages/minerender/test/SectionMesh.test.ts index 9a8899a0..4b0d2b65 100644 --- a/packages/minerender/test/SectionMesh.test.ts +++ b/packages/minerender/test/SectionMesh.test.ts @@ -4,6 +4,7 @@ import { BoxGeometry, Camera, Color, DoubleSide, DynamicDrawUsage, Float32Buffer import { CanvasImage } from "../src/canvas/CanvasImage"; import { CompatCanvas } from "../src/canvas/CanvasCompat"; import { Env, EnvProvider } from "../src/Env"; +import { Logging, LogHandler } from "../src/util/log"; import { TextureAtlas } from "../src/texture/TextureAtlas"; import { Ticker } from "../src/Ticker"; import { buildSectionGeometry, SectionGeometryInput, SectionGeometryPage } from "../src/world/SectionGeometry"; @@ -474,6 +475,9 @@ test.serial("async section builds use the shared worker without copying its geom }); test.serial("async section builds fall back after a worker error and disable the shared worker", async t => { + const messages: Parameters[] = []; + Logging.setHandler((...message) => { messages.push(message); }); + t.teardown(() => Logging.setHandler()); const target = new EventTarget(); let workers = 0, terminations = 0; const worker = Object.assign(target, { @@ -491,9 +495,13 @@ test.serial("async section builds fall back after a worker error and disable the t.is(SectionWorker.shared(), undefined); t.is(workers, 1); t.is(terminations, 1); + t.deepEqual(messages, [["warn", "SectionWorker", ["Section worker failed, so sections build on the main thread"]]]); }); test.serial("async section builds fall back after a message error and disable the shared worker", async t => { + const messages: Parameters[] = []; + Logging.setHandler((...message) => { messages.push(message); }); + t.teardown(() => Logging.setHandler()); const target = new EventTarget(); let terminations = 0; const worker = Object.assign(target, { @@ -507,6 +515,7 @@ test.serial("async section builds fall back after a message error and disable th t.deepEqual(geometryData(actual), geometryData(expected)); t.is(SectionWorker.shared(), undefined); t.is(terminations, 1); + t.deepEqual(messages, [["warn", "SectionWorker", ["Section worker message could not be deserialized, so sections build on the main thread"]]]); }); test.serial("async section builds fall back for a rejected build without disabling the worker", async t => {