diff --git a/AGENTS.md b/AGENTS.md index fe898071..224cda71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The private Yarn workspace root contains the public library and its consumers. U **Entities** (`src/entity/`): `Entities.getEntity` loads a versioned `entity-models` file. By default it selects `main` plus the dataset `passes` that have no `when` label or whose label is in `options.when` (`getPassList` lists them); `layer`/`layers` select exactly the named layers in order, without passes. `textures` overrides are keyed like `EntityModel.layers`, where a further draw of the same geometry is `#` from 2. `getLayerList` lists available names. Each draw uses the material for its dataset `render` mode (`Materials.createEntityCanvasMaterial`), cached per texture, mode, and tint; modes that vanilla culls get no inward faces. `EntityObject` `tints` maps pass tint labels to colours. `energy_swirl` and `breeze_wind` materials are owned by their object: `Ticker` advances `EntityObject.age` and the texture offset while the object has a parent; removal pauses it and reattachment resumes it. `EntityObject.getLayerGroup` and layer-scoped `getGroupByName`/`getMeshByName` expose each layer’s visibility and poses. Parts form a nested `Object3D` hierarchy with radian poses, inherited texture dimensions, cube growth, and mirrored box UVs. The entity root applies the dataset's `transform` (vanilla's renderer operations for the default state: living entities face +Z with their feet at the origin); models without one get scale `(-1, -1, 1)`. Nested child nodes hold this transform, one per operation, so every operation order is exact and caller position, rotation, and scale apply on top of it. `flip: true` forces that plain flip and `flip: false` keeps raw model space. Without an explicit texture key, use dataset `textureLocation` first, then the resolver (model path, a same-named texture in its directory, then variant data); `getBlock` and `getBlockList` are deprecated aliases. `Entities.getDatasetRoot` loads geometry, lists, animations, and the block index from the nearest published release when the exact dataset version is missing; textures keep the requested root. `src/bedrock/` contains type declarations only. -**World** (`src/world/`): `MineRenderWorld` is an early prototype with sparse chunks keyed by signed coordinates. Cubic 16³ `Chunk`s store `ChunkData` palettes with immutable states and recycled unused entries, 4,096 `Uint16` state indices, and per-cell NBT. Render handles and stable `BlockInfo` views remain separate; `.block` returns detached snapshots. Block coordinates are integers; chunk-local coordinates are 0–15. World and owned-chunk setters refresh `cullface` masks after edits, including across chunk boundaries; only opaque full-cube models occlude neighbors. The versioned `blockCulling` dataset lists vanilla's further face rules, applied in both rendering modes: glass, ice, slime and similar blocks hide faces toward the same block, mangrove roots toward the same block above or below, and connected panes or bars toward each other. Leaves keep their faces, as vanilla's cutout leaves do; data without `blockCulling` applies no such rule. `new MineRenderWorld(scene, { sectionMeshing: true })` merges models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces into per-section meshes with separate solid, translucent, and fluid atlas pages (at most 2048×2048 pixels by default; `maxAtlasSize`), with one mesh per page. Page selection uses texture alpha (`hasTranslucency`), so plain glass and panes use the solid/cutout page where vanilla uses the translucent layer. Translucent quads within a section are not depth-sorted. Per-position flower offsets remain unsupported. Only block entities keep individual objects; only opaque full cubes occlude neighbors. Animated atlases occupy their own pages and redraw on frame changes. Fluid surfaces use an 18×18×18 cell neighborhood and are built in the section worker when available. The section worker entry must stay free of three, DOM, and Node imports, so pure geometry code lives in `SectionGeometry.ts` and `FluidQuads.ts`. Merged `BlockInfo.object` values are `undefined`; edit through world/chunk setters. Replacing, removing, or clearing blocks releases their instance slots. `await world.unloadChunkColumn(x, z)` removes every section at absolute chunk-column coordinates and refreshes neighboring faces and fluids. `await world.unloadChunkColumns([{ x, z }, ...])` validates, copies, and deduplicates a group before removal, then refreshes surviving faces, fluids, lighting, AO, and biome colors once. `await world.setBlockVisibleAt(position, visible)` preserves block data and reveals neighboring faces in both rendering modes; replacement or removal resets visibility. Without an executor, parsed columns copy each section's palette into the chunk, resolve each palette entry once, and fill cells in one synchronous pass; `placeMultiBlock` groups blocks per chunk and resolves each distinct state once per group, with weighted variants chosen per cell and yields after about 8 ms of work. Passing a `BatchedExecutor` keeps the previous per-position batches, order at repeated positions, and wait-for-active-batch failure semantics. Bulk placement applies saved properties before model creation and flushes neighbor culling once, including after partial failure; single-block edits update neighbors immediately. Parsed column placement and unloading mark whole sections and neighboring sections' shared boundary cells dirty; structure placement and single edits queue chunk-local indices, and the culling drain recomputes masks in a flat pass per section and rebuilds each changed section once through `SectionMesh.buildAsync`, which runs the pure `buildSectionGeometry` (`SectionGeometry.ts`, typed arrays in and out) in the browser section worker when `Env.provider.createWorker` returns one and synchronously otherwise; a rebuild superseded by a newer rebuild or `clear()` is disposed. `BatchedExecutor.submit` returns a task promise; `stop()` rejects queued and future tasks while active tasks finish. Idle executors hold no timer; caller-provided executors remain caller-owned. Saved biomes supply grass, foliage, dry-foliage, and per-cell water colors in both rendering modes. Saved lighting, corner ambient occlusion, and biome blending are opt-in; light propagation, exact partial-shape ambient occlusion, and LOD remain unsupported. The demos in `apps/web/` exercise `MineRenderWorld`. +**World** (`src/world/`): `MineRenderWorld` is an early prototype with sparse chunks keyed by signed coordinates. Cubic 16³ `Chunk`s store `ChunkData` palettes with immutable states and recycled unused entries, 4,096 `Uint16` state indices, and per-cell NBT. Render handles and stable `BlockInfo` views remain separate; `.block` returns detached snapshots. Block coordinates are integers; chunk-local coordinates are 0–15. World and owned-chunk setters refresh `cullface` masks after edits, including across chunk boundaries; only opaque full-cube models occlude neighbors. The versioned `blockCulling` dataset lists vanilla's further face rules, applied in both rendering modes: glass, ice, slime and similar blocks hide faces toward the same block, mangrove roots toward the same block above or below, and connected panes or bars toward each other. Leaves keep their faces, as vanilla's cutout leaves do; data without `blockCulling` applies no such rule. `new MineRenderWorld(scene, { sectionMeshing: true })` merges models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces into per-section meshes with separate solid, translucent, and fluid atlas pages (at most 2048×2048 pixels by default; `maxAtlasSize`), with one mesh per page. Page selection uses texture alpha (`hasTranslucency`), so plain glass and panes use the solid/cutout page where vanilla uses the translucent layer. Transparent model and fluid pages draw their quads back to front: perspective views sort by distance from the camera and re-sort when it enters another block, as vanilla does; orthographic views sort by view depth and re-sort when the view direction changes. Sorting stays within each atlas page; intersecting surfaces can still blend incorrectly, and Three.js sorts the meshes for different pages or sections as whole objects. Per-position flower offsets remain unsupported. Only block entities keep individual objects; only opaque full cubes occlude neighbors. Animated atlases occupy their own pages and redraw on frame changes. Fluid surfaces use an 18×18×18 cell neighborhood and are built in the section worker when available. The section worker entry must stay free of three, DOM, and Node imports, so pure geometry code lives in `SectionGeometry.ts` and `FluidQuads.ts`. Merged `BlockInfo.object` values are `undefined`; edit through world/chunk setters. Replacing, removing, or clearing blocks releases their instance slots. `await world.unloadChunkColumn(x, z)` removes every section at absolute chunk-column coordinates and refreshes neighboring faces and fluids. `await world.unloadChunkColumns([{ x, z }, ...])` validates, copies, and deduplicates a group before removal, then refreshes surviving faces, fluids, lighting, AO, and biome colors once. `await world.setBlockVisibleAt(position, visible)` preserves block data and reveals neighboring faces in both rendering modes; replacement or removal resets visibility. Without an executor, parsed columns copy each section's palette into the chunk, resolve each palette entry once, and fill cells in one synchronous pass; `placeMultiBlock` groups blocks per chunk and resolves each distinct state once per group, with weighted variants chosen per cell and yields after about 8 ms of work. Passing a `BatchedExecutor` keeps the previous per-position batches, order at repeated positions, and wait-for-active-batch failure semantics. Bulk placement applies saved properties before model creation and flushes neighbor culling once, including after partial failure; single-block edits update neighbors immediately. Parsed column placement and unloading mark whole sections and neighboring sections' shared boundary cells dirty; structure placement and single edits queue chunk-local indices, and the culling drain recomputes masks in a flat pass per section and rebuilds each changed section once through `SectionMesh.buildAsync`, which runs the pure `buildSectionGeometry` (`SectionGeometry.ts`, typed arrays in and out) in the browser section worker when `Env.provider.createWorker` returns one and synchronously otherwise; a rebuild superseded by a newer rebuild or `clear()` is disposed. `BatchedExecutor.submit` returns a task promise; `stop()` rejects queued and future tasks while active tasks finish. Idle executors hold no timer; caller-provided executors remain caller-owned. Saved biomes supply grass, foliage, dry-foliage, and per-cell water colors in both rendering modes. Saved lighting, corner ambient occlusion, and biome blending are opt-in; light propagation, exact partial-shape ambient occlusion, and LOD remain unsupported. The demos in `apps/web/` exercise `MineRenderWorld`. `world.raycast(raycaster)` returns the nearest visible rendered block: integer `position`, detached `block` data, and scene-space `point`, `normal`, and `distance`. It resolves merged geometry through per-quad cell indices and individual geometry through owned model/instance references. Hidden blocks and unrelated scene objects are excluded; transparent texture pixels still participate in geometric picking. diff --git a/ROADMAP.md b/ROADMAP.md index 8aec2fac..fb12a7a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,6 +98,8 @@ Regenerate and publish the `1.21.11` entity dataset with the procedural clips fr ### 11. World subsystem redesign for scale — high (the V2 differentiator) Immediate fixes: ~~fix `getChunkAt` to use `Map.get(key)`~~; ~~remove the 4×4×4 bound and negative-coordinate rejection~~; ~~remove hardcoded debug wireframes~~; ~~fix `BatchedExecutor`'s missing setInterval delay + add `stop()`~~; ~~place structures and chunks in bounded batches with one final neighbor-culling pass~~. Then the redesign: ~~palette + typed-array section storage~~, ~~opt-in merged meshes and bounded atlas pages per chunk section for models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces~~ (block entities retain per-block objects), ~~neighbor face culling via model `cullface` against opaque full cubes~~ (vanilla's same-block and connected-block rules from the `blockCulling` dataset also hide shared faces; partial-shape rules remain), ~~explicit chunk load/unload~~, ~~`await world.unloadChunkColumns(positions)` removes columns together and refreshes surviving neighbors once; the streamer requires this method, which `MineRenderWorld` provides, for eviction and disposal~~, ~~per-block visibility without deleting block data~~, ~~camera-driven streaming~~. ~~Opt-in baked per-vertex ambient occlusion from opaque full cubes, with runtime toggling.~~ Exact partial-shape ambient occlusion, chunk-level visibility management, and LOD remain. Section meshes already have bounds for Three.js frustum culling. +~~Sort transparent model and fluid quads back to front within each atlas page.~~ Intersecting surfaces and quad ordering across pages or sections remain approximate. + ~~Preserve modern Anvil biome palettes during import and placement, including air-only sections, with world-coordinate lookup.~~ ~~Apply biome colors to grass, foliage, dry foliage, and water in both rendering modes, including merged fluid sections, resource-pack colormaps, and dark forest/swamp grass modifiers.~~ ~~Add opt-in biome color blending in section meshes with a runtime radius, cached color averaging, and border refreshes after biome edits or streaming.~~ Older numeric biome arrays and world-seed-based biome sampling remain unsupported. ~~Import saved sky/block light and apply opt-in flat lighting to blocks and fluids in section meshes.~~ ~~Add opt-in ambient occlusion to axis-aligned boundary block faces in section meshes, honoring model opt-outs and refreshing edited or streamed neighbors.~~ Exact partial-shape ambient occlusion and light propagation remain. Measure real-world decoding, placement, frame stalls, and retained memory before choosing further worker or LOD work. The [world-streaming benchmark](./apps/web/benchmarks/README.md) records fixed local-save routes with cold and warm asset passes, operation timings, frame-callback gaps, and JavaScript heap samples. ~~Seed weighted blockstate alternatives by world position so unloading and reloading a chunk preserves its appearance.~~ Individual blocks and section meshes share vanilla-identical weighted selection from absolute block coordinates. Standalone previews remain random unless given `variantPosition`. diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index 4192dd74..13970130 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -125,6 +125,8 @@ Calling `removeFromScene()` or `dispose()` on an instance reference releases its With `sectionMeshing: true`, [MineRenderWorld](/api/index/classes/MineRenderWorld) merges eligible blocks into section meshes. A merged block has no individual `BlockInfo.object`. Edit it through the world or chunk setters so geometry and neighbor culling update together. +Translucent blocks and water in a section mesh draw back to front. Perspective views sort them by distance from the camera and re-sort when the camera enters another block; orthographic views sort by view depth. Separate section meshes rely on Three.js sorting the meshes as whole objects, and intersecting surfaces can still blend incorrectly. + Block model elements with `shade: false` skip directional face shading in separate, merged, and instanced models, including world section meshes. Tints, saved world light, and ambient occlusion still apply. Item previews keep directional shading because vanilla's item renderer ignores the flag. Omitted or `true` values retain directional shading; GUI items with `gui_light: "front"` disable it for the whole model. World placement selects weighted block models from absolute block coordinates, so unloading and reloading preserves their appearance in both rendering modes. Standalone `scene.addBlock` previews remain random unless you supply `variantPosition: [x, y, z]` in block units. The position is copied at construction; moving the object does not change its selection. The pick is vanilla-identical for the same coordinates and ordered weights. Position-based selection rejects total weights above 2,147,483,647. diff --git a/packages/minerender/src/world/SectionMesh.ts b/packages/minerender/src/world/SectionMesh.ts index 2cdf6cb7..b2128712 100644 --- a/packages/minerender/src/world/SectionMesh.ts +++ b/packages/minerender/src/world/SectionMesh.ts @@ -1,11 +1,11 @@ -import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, FrontSide, Group, Material, Mesh, MeshBasicMaterial, ShaderMaterial, Texture, Uint32BufferAttribute } from "three"; +import { BufferGeometry, Color, DoubleSide, DynamicDrawUsage, Float32BufferAttribute, FrontSide, Group, Material, Matrix4, Mesh, MeshBasicMaterial, ShaderMaterial, Texture, Uint32BufferAttribute, Vector3 } from "three"; import { createCanvas } from "../canvas/CanvasCompat"; import { Materials } from "../Materials"; import type { MineRenderScene } from "../renderer/MineRenderScene"; import { TextureAtlas } from "../texture/TextureAtlas"; import { buildSectionGeometry, SectionGeometryInput, SectionGeometryPage, SectionTemplateData } from "./SectionGeometry"; import { SectionWorker } from "./SectionWorker"; -import type { Intersection } from "three"; +import type { Camera, Intersection } from "three"; /** Shared quad geometry and atlas data prepared for section merging. */ export interface SectionMeshTemplate { @@ -38,6 +38,10 @@ export interface SectionFluids { lava?: TextureAtlas; } +const BLOCK_SIZE = 16; +const QUAD_VERTICES = 4; +const QUAD_INDICES = 6; + const templateCache = new WeakMap(); const WATER_TINT = new Color(0x3f76e4).toArray() as [number, number, number]; @@ -70,13 +74,13 @@ function toInput(entries: readonly SectionMeshEntry[], maxAtlasSize: number, flu if (!data) { const indices = template.geometry.getIndex()!; const position = template.geometry.getAttribute("position"); - const quads = indices.count / 6; - if (indices.count % 6 !== 0 || position.count !== quads * 4) { + const quads = indices.count / QUAD_INDICES; + if (indices.count % QUAD_INDICES !== 0 || position.count !== quads * QUAD_VERTICES) { throw new RangeError("Section template geometry must hold four vertices and six indices per quad"); } data = { quads, positions: new Float32Array(quads * 12), normals: new Float32Array(quads * 12), uvs: new Float32Array(quads * 8), - uvBounds: new Float32Array(quads * 16), colors: new Float32Array(quads * 12), shade: new Float32Array(quads * 4), indices: new Uint16Array(quads * 6), + uvBounds: new Float32Array(quads * 16), colors: new Float32Array(quads * 12), shade: new Float32Array(quads * QUAD_VERTICES), indices: new Uint16Array(quads * QUAD_INDICES), cullFaces: new Uint8Array(template.cullFaces), biomeTint: template.biomeTint ? new Uint8Array(template.biomeTint) : undefined, atlas: 0, layer: template.layer, ambientOcclusion: template.ambientOcclusion }; const normal = template.geometry.getAttribute("normal"); @@ -84,7 +88,7 @@ function toInput(entries: readonly SectionMeshEntry[], maxAtlasSize: number, flu const bounds = template.geometry.getAttribute("uvBounds"); const color = template.geometry.getAttribute("color"); const shade = template.geometry.getAttribute("shade"); - for (let vertex = 0; vertex < quads * 4; vertex++) { + for (let vertex = 0; vertex < quads * QUAD_VERTICES; vertex++) { data.positions.set([position.getX(vertex), position.getY(vertex), position.getZ(vertex)], vertex * 3); data.normals.set([normal.getX(vertex), normal.getY(vertex), normal.getZ(vertex)], vertex * 3); data.uvs.set([uv.getX(vertex), uv.getY(vertex)], vertex * 2); @@ -93,7 +97,7 @@ function toInput(entries: readonly SectionMeshEntry[], maxAtlasSize: number, flu data.colors.set([color?.getX(vertex) ?? 1, color?.getY(vertex) ?? 1, color?.getZ(vertex) ?? 1], vertex * 3); data.shade![vertex] = shade?.getX(vertex) ?? 1; } - for (let i = 0; i < data.indices.length; i++) data.indices[i] = indices.getX(i) - 4 * Math.floor(i / 6); + for (let i = 0; i < data.indices.length; i++) data.indices[i] = indices.getX(i) - QUAD_VERTICES * Math.floor(i / QUAD_INDICES); templateCache.set(template, data); } id = input.templateData.length; @@ -126,6 +130,57 @@ function toInput(entries: readonly SectionMeshEntry[], maxAtlasSize: number, flu return { input, atlases }; } +function sortTransparentQuads(mesh: Mesh): void { + const index = mesh.geometry.getIndex()!; + if (index.count <= QUAD_INDICES) return; + index.setUsage(DynamicDrawUsage); + const count = index.count / QUAD_INDICES; + let source: Uint32Array, centers: Float32Array, depths: Float32Array, order: Uint32Array; + const view = new Matrix4(), localCamera = new Vector3(), key = new Vector3(), previous = new Vector3(); + let previousOrthographic: boolean | undefined; + mesh.onBeforeRender = (_renderer, _scene, camera) => { + localCamera.setFromMatrixPosition(camera.matrixWorld).applyMatrix4(view.copy(mesh.matrixWorld).invert()); + const orthographic = !!(camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera; + if (orthographic) { + // Three updates mesh.modelViewMatrix after this callback. + view.multiplyMatrices(camera.matrixWorldInverse, mesh.matrixWorld); + key.set(view.elements[2], view.elements[6], view.elements[10]); + } else { + key.copy(localCamera).divideScalar(BLOCK_SIZE).floor(); + } + if (orthographic === previousOrthographic && key.equals(previous)) return; + previous.copy(key); + previousOrthographic = orthographic; + if (!centers) { + source = new Uint32Array(index.array); + centers = new Float32Array(count * 3); + depths = new Float32Array(count); + order = Uint32Array.from({ length: count }, (_, quad) => quad); + const positions = mesh.geometry.getAttribute("position"); + for (let quad = 0; quad < count; quad++) { + for (let axis = 0; axis < 3; axis++) { + let center = 0; + for (let corner = 0; corner < QUAD_VERTICES; corner++) center += positions.getComponent(quad * QUAD_VERTICES + corner, axis); + centers[quad * 3 + axis] = center / QUAD_VERTICES; + } + } + } + for (let quad = 0; quad < count; quad++) { + const x = centers[quad * 3], y = centers[quad * 3 + 1], z = centers[quad * 3 + 2]; + depths[quad] = orthographic ? key.x * x + key.y * y + key.z * z + : -((x - localCamera.x) ** 2 + (y - localCamera.y) ** 2 + (z - localCamera.z) ** 2); + } + order.sort((a, b) => depths[a] - depths[b] || a - b); + if (!order.some((quad, offset) => index.array[offset * QUAD_INDICES] !== source[quad * QUAD_INDICES])) return; + for (let offset = 0; offset < count; offset++) { + for (let indexOffset = 0; indexOffset < QUAD_INDICES; indexOffset++) { + index.array[offset * QUAD_INDICES + indexOffset] = source[order[offset] * QUAD_INDICES + indexOffset]; + } + } + index.needsUpdate = true; + }; +} + /** Merged terrain geometry and atlas pages for one 16×16×16 section. Owns its generated render resources. */ export class SectionMesh extends Group { @@ -135,7 +190,12 @@ export class SectionMesh extends Group { /** Returns the owning section cell for a triangle hit on this mesh's merged geometry. */ public getBlockIndex(hit: Intersection): number | undefined { if (hit.faceIndex === undefined || hit.faceIndex === null) return undefined; - return this.blockIndices.get(hit.object as Mesh)?.[Math.floor(hit.faceIndex / 2)]; + const mesh = hit.object as Mesh; + const owners = this.blockIndices.get(mesh); + if (!owners) return undefined; + // Sorted triangles still refer to each quad's original four consecutive vertices. + const vertex = mesh.geometry.getIndex()!.getX(hit.faceIndex * 3); + return owners[Math.floor(vertex / QUAD_VERTICES)]; } /** Builds section-local meshes from visible quads. `maxAtlasSize` limits each atlas dimension in pixels. */ @@ -177,13 +237,14 @@ export class SectionMesh extends Group { const material = Materials.createShadedCanvasMaterial(canvas as HTMLCanvasElement, transparent, false, true); material.side = FrontSide; if (page.layer === 2) { + material.depthWrite = !material.transparent; material.side = DoubleSide; material.forceSinglePass = true; - material.depthWrite = !material.transparent; } material.vertexColors = true; const texture = (material as ShaderMaterial).uniforms?.map?.value ?? (material as MeshBasicMaterial).map; const mesh = new Mesh(geometry, material); + if (transparent) sortTransparentQuads(mesh); section.add(mesh); section.blockIndices.set(mesh, page.blockIndices); const unsubscribe: (() => void)[] = []; diff --git a/packages/minerender/test/SectionMesh.test.ts b/packages/minerender/test/SectionMesh.test.ts index 90ce23d2..9a8899a0 100644 --- a/packages/minerender/test/SectionMesh.test.ts +++ b/packages/minerender/test/SectionMesh.test.ts @@ -1,6 +1,6 @@ import { installMineRenderDataFixtures } from "./helpers/minerender-data"; import test, { ExecutionContext } from "ava"; -import { BoxGeometry, Color, DoubleSide, Float32BufferAttribute, FrontSide, Group, Matrix4, Mesh, Raycaster, ShaderMaterial, Texture, Vector3 } from "three"; +import { BoxGeometry, Camera, Color, DoubleSide, DynamicDrawUsage, Float32BufferAttribute, FrontSide, Group, Matrix4, Mesh, OrthographicCamera, PerspectiveCamera, Raycaster, ShaderMaterial, Texture, Vector3 } from "three"; import { CanvasImage } from "../src/canvas/CanvasImage"; import { CompatCanvas } from "../src/canvas/CanvasCompat"; import { Env, EnvProvider } from "../src/Env"; @@ -39,6 +39,19 @@ function fixture(t: ExecutionContext, createWorker?: EnvProvider["createWorker"] }; } +function beforeRender(section: SectionMesh, camera: Camera) { + section.updateWorldMatrix(true, true); + camera.updateWorldMatrix(true, false); + for (const child of section.children) { + const mesh = child as Mesh; + mesh.onBeforeRender(null!, null!, camera, mesh.geometry, mesh.material as ShaderMaterial, null!); + } +} + +function quadOrder(mesh: Mesh): number[] { + return Array.from(mesh.geometry.getIndex()!.array).filter((_, index) => index % 6 === 0).map(vertex => Math.floor(vertex / 4)); +} + test.serial("section faces retain baked rotation, tint and atlas UVs without changing shared templates", t => { const create = fixture(t); const first = create(), second = create(); @@ -117,6 +130,140 @@ test.serial("section materials blend only translucent pages", t => { const materials = section.children.map(child => (child as Mesh).material as ShaderMaterial); t.deepEqual(materials.map(material => material.transparent), [false, true]); t.deepEqual(materials.map(material => material.side), [FrontSide, FrontSide]); + t.deepEqual(materials.map(material => material.depthWrite), [true, true]); + const opaque = section.children[0] as Mesh; + const original = Array.from(opaque.geometry.getIndex()!.array), version = opaque.geometry.getIndex()!.version; + const camera = new PerspectiveCamera(); + for (const z of [100, -100]) { + camera.position.z = z; + camera.lookAt(0, 0, 0); + beforeRender(section, camera); + } + t.deepEqual(Array.from(opaque.geometry.getIndex()!.array), original); + t.is(opaque.geometry.getIndex()!.version, version); +}); + +test.serial("perspective sections sort by distance only after entering another block or changing camera kind", t => { + const template = fixture(t)(1); + const section = SectionMesh.build([0, 1].map(index => ({ index, template, cullMask: 47 }))); + t.teardown(() => section.dispose()); + const mesh = section.children[0] as Mesh, index = mesh.geometry.getIndex()!; + const camera = new PerspectiveCamera(); + camera.position.set(1, 0, 100); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0]); + const version = index.version; + camera.rotation.y = Math.PI; + beforeRender(section, camera); + t.is(index.version, version); + camera.position.x = 15; + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0]); + t.is(index.version, version); + camera.position.x = 16; + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [0, 1]); + t.is(index.version, version + 1); + camera.position.x = 32; + beforeRender(section, camera); + t.is(index.version, version + 1); + camera.position.x = 1; + beforeRender(section, camera); + const orthographic = new OrthographicCamera(); + orthographic.position.copy(camera.position); + beforeRender(section, orthographic); + t.deepEqual(quadOrder(mesh), [0, 1]); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0]); +}); + +test.serial("orthographic sections sort by view depth, retain original ties, and skip translation uploads", t => { + const template = fixture(t)(1); + const section = SectionMesh.build([0, 16, 1].map(index => ({ index, template, cullMask: 47 }))); + t.teardown(() => section.dispose()); + const mesh = section.children[0] as Mesh; + const index = mesh.geometry.getIndex()!; + const attributes = Object.fromEntries(Object.entries(mesh.geometry.attributes).map(([name, attribute]) => [name, attribute.array.slice()])); + const bounds = mesh.geometry.boundingBox!.clone(), sphere = mesh.geometry.boundingSphere!.clone(); + const camera = new OrthographicCamera(-64, 64, 64, -64); + camera.position.z = 100; + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [0, 2, 1]); + t.is(index.usage, DynamicDrawUsage); + const version = index.version; + beforeRender(section, camera); + t.is(index.version, version); + camera.position.z = 120; + beforeRender(section, camera); + t.is(index.version, version); + camera.position.z = -100; + camera.lookAt(0, 0, 0); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0, 2]); + t.is(index.version, version + 1); + camera.position.set(-100, 0, 0); + camera.lookAt(0, 0, 0); + beforeRender(section, camera); + t.is(quadOrder(mesh)[0], 2); + camera.position.set(0, 0, 100); + camera.lookAt(0, 0, 0); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [0, 2, 1]); + t.deepEqual(Object.fromEntries(Object.entries(mesh.geometry.attributes).map(([name, attribute]) => [name, attribute.array])), attributes); + t.deepEqual(mesh.geometry.boundingBox, bounds); + t.deepEqual(mesh.geometry.boundingSphere, sphere); +}); + +test.serial("translucent sorting uses camera and mesh parent transforms before model-view matrices are refreshed", t => { + const template = fixture(t)(1); + const section = SectionMesh.build([0, 16].map(index => ({ index, template, cullMask: 47 }))); + t.teardown(() => section.dispose()); + const mesh = section.children[0] as Mesh; + const parent = new Group(); + parent.position.set(13, 5, -40); + parent.rotation.y = Math.PI; + parent.scale.set(2, 3, 4); + parent.add(section); + const camera = new PerspectiveCamera(); + camera.position.set(13, 5, 100); + mesh.modelViewMatrix.identity(); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0]); + const cameraParent = new Group(); + cameraParent.position.copy(parent.position); + cameraParent.rotation.y = Math.PI; + cameraParent.add(camera); + camera.position.set(0, 0, 100); + mesh.modelViewMatrix.makeRotationY(Math.PI); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [0, 1]); + mesh.rotation.y = Math.PI; + mesh.modelViewMatrix.identity(); + beforeRender(section, camera); + t.deepEqual(quadOrder(mesh), [1, 0]); +}); + +test.serial("sorted translucent quads retain block ownership for ray hits on both triangles", t => { + const template = fixture(t)(1); + const section = SectionMesh.build([0, 18].map(index => ({ index, template, cullMask: 47 }))); + t.teardown(() => section.dispose()); + const camera = new PerspectiveCamera(); + for (const z of [100, -100]) { + camera.position.z = z; + camera.lookAt(0, 0, 0); + beforeRender(section, camera); + for (const [x, owner] of [[0, 0], [32, 18]]) { + const triangles = new Set(); + for (const [dx, y] of [[-4, 2], [4, -2]]) { + const ray = new Raycaster(new Vector3(x + dx, y, 100), new Vector3(0, 0, -1)); + const hit = ray.intersectObject(section, true)[0]; + t.truthy(hit); + t.is(section.getBlockIndex(hit), owner); + triangles.add(hit.faceIndex! % 2); + } + t.deepEqual([...triangles].sort(), [0, 1]); + } + } }); for (const transparent of [false, true]) { @@ -125,8 +272,8 @@ for (const transparent of [false, true]) { const atlas = new TextureAtlas({}, new CanvasImage(4, 2), { still: [2, 2], flow: [2, 2] }, { still: [0, 0], flow: [2, 0] }, false, {}, transparent, transparent); const cells = new Uint8Array(18 ** 3); - cells[18 * 18 + 18 + 1] = 16; - const section = SectionMesh.build([], 4, { cells, water: atlas }); + cells[18 * 18 + 18 + 1] = transparent ? 16 : 32; + const section = SectionMesh.build([], 4, { cells, [transparent ? "water" : "lava"]: atlas }); t.teardown(() => section.dispose()); t.is(section.children.length, 1); const material = (section.children[0] as Mesh).material as ShaderMaterial; @@ -136,6 +283,22 @@ for (const transparent of [false, true]) { t.is(material.depthWrite, !transparent); t.true(material.vertexColors); t.deepEqual(Array.from((section.children[0] as Mesh).geometry.getAttribute("shade").array), Array(24).fill(1)); + const mesh = section.children[0] as Mesh, index = mesh.geometry.getIndex()!; + const original = Array.from(index.array), version = index.version; + const camera = new PerspectiveCamera(); + camera.position.set(8, 8, 100); + beforeRender(section, camera); + const forward = Array.from(index.array); + camera.position.z = -100; + camera.lookAt(8, 8, 0); + beforeRender(section, camera); + if (transparent) { + t.notDeepEqual(Array.from(index.array), forward); + t.is(index.usage, DynamicDrawUsage); + } else { + t.deepEqual(Array.from(index.array), original); + t.is(index.version, version); + } }); } @@ -257,7 +420,7 @@ test.serial("async section builds use the shared worker without copying its geom terminate() {} }) as unknown as Worker; const create = fixture(t, () => { workers++; return worker; }); - const first = create(), second = create(); + const first = create(1), second = create(1); first.geometry.applyMatrix4(new Matrix4().makeRotationY(Math.PI / 2)); first.biomeTint = new Uint8Array([1, 0, 1, 0, 1, 0]); first.geometry.setAttribute("shade", new Float32BufferAttribute([0, 1, 0, 1, 0, 1].flatMap(value => Array(4).fill(value)), 1)); @@ -295,6 +458,19 @@ test.serial("async section builds use the shared worker without copying its geom t.is(actual[i].getBlockIndex(hit), index); } } + const camera = new PerspectiveCamera(); + for (const z of [100, -100]) { + camera.position.z = z; + camera.lookAt(0, 0, 0); + for (const section of [...expected, ...actual]) beforeRender(section, camera); + t.deepEqual(actual.map(geometryData), expected.map(geometryData)); + for (const section of actual) { + for (const [x, z, index] of [[16, 0, 1], [0, 16, 16], [64, 48, 564]]) { + const ray = new Raycaster(new Vector3(x, 100, z), new Vector3(0, -1, 0)); + t.is(section.getBlockIndex(ray.intersectObject(section, true)[0]), index); + } + } + } }); test.serial("async section builds fall back after a worker error and disable the shared worker", async t => {