From 80319747cf82764a1a2beddf3dd37ccdbb738333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 12:43:23 +0200 Subject: [PATCH 1/6] Add a reproducible merged-world transparency demo --- apps/web/README.md | 1 + apps/web/src/demo/transparency/fixture.ts | 77 +++++++++++++++ apps/web/src/demo/transparency/index.html | 45 +++++++++ apps/web/src/demo/transparency/script.ts | 111 ++++++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 apps/web/src/demo/transparency/fixture.ts create mode 100644 apps/web/src/demo/transparency/index.html create mode 100644 apps/web/src/demo/transparency/script.ts diff --git a/apps/web/README.md b/apps/web/README.md index 036455ad..b6dc9957 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -30,6 +30,7 @@ These paths are relative to the development server root: | `demo/custom_model/` | Java model JSON editor | | `demo/exports/` | Image, video, and 3D export of a small instanced scene | | `demo/materials/` | Canvas material shader regression check | +| `demo/transparency/` | Reproduces translucent face ordering in merged world sections from opposite camera views | The sidebar from `src/playground/Playground.ts` provides presets, renderer and camera settings, Minecraft version / hosted root / resource-pack ZIP, image, video, and 3D exports, and a shareable link or JSON that restores the page state. Local files are not part of shared links and have to be selected again. diff --git a/apps/web/src/demo/transparency/fixture.ts b/apps/web/src/demo/transparency/fixture.ts new file mode 100644 index 00000000..eecea320 --- /dev/null +++ b/apps/web/src/demo/transparency/fixture.ts @@ -0,0 +1,77 @@ +import { AssetLoader, AssetParser, AssetSource, Caching, MineRenderScene, MineRenderWorld, + type AssetKey, type MinecraftAsset, type MineRenderDataManifest, type Model, type TextureAsset } from "minerender"; + +const NAMESPACE = "transparency"; +type SlabColor = "red" | "blue"; + +function texture(color: SlabColor): TextureAsset { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d")!; + const pixels = context.createImageData(1, 1); + pixels.data.set(color === "red" ? [255, 0, 0, 128] : [0, 0, 255, 128]); + context.putImageData(pixels, 0, 0); + const encoded = atob(canvas.toDataURL("image/png").split(",")[1]); + return { width: 1, height: 1, type: "png", data: Uint8Array.from(encoded, character => character.charCodeAt(0)) }; +} + +function model(color: SlabColor): Model { + return { + textures: { surface: `${NAMESPACE}:block/${color}` }, + elements: [{ from: [1, 1, 7.9], to: [15, 15, 8.1], shade: false, + faces: { north: { texture: "#surface" }, south: { texture: "#surface" } } }] + }; +} + +class FixtureSource extends AssetSource { + private readonly textures = { red: texture("red"), blue: texture("blue") }; + + constructor() { super(); } + + public blocks(): boolean { return true; } + + public async get(key: AssetKey, parser: AssetParser | string): Promise { + let asset: unknown; + if (key.namespace === "minerender-data") { + if (key.path === "manifest") { + const dataset = (file: string) => ({ file: `${file}.json`, sha256: "0".repeat(64), bytes: 2, provenance: {} }); + const manifest: MineRenderDataManifest = { + schemaVersion: 1, minecraftVersion: AssetLoader.version, dataVersion: 0, + datasets: { blockStates: dataset("block-states"), fluids: dataset("fluids"), blockTints: dataset("block-tints") }, + unavailable: {}, extractor: { repository: "fixture", revision: "fixture", sha256: "0".repeat(64) } + }; + asset = manifest; + } else if (["block-states", "fluids", "block-tints"].includes(key.path)) asset = {}; + } else if (key.namespace === "entity-models" && key.path === "blocks") asset = {}; + else if (key.namespace === NAMESPACE && (key.path === "red" || key.path === "blue")) { + if (parser === AssetParser.BLOCKSTATE) asset = { variants: { "": { model: `${NAMESPACE}:block/${key.path}` } } }; + else if (parser === AssetParser.MODEL) asset = model(key.path); + else if (parser === AssetParser.IMAGE) asset = { ...this.textures[key.path] }; + } + return asset as T | undefined; + } +} + +let installed = false; + +/** Installs local fixture assets and prevents fallback requests to hosted sources. */ +export function installFixtureAssets(): void { + if (installed) return; + AssetLoader.addSource("transparency-fixture", new FixtureSource()); + Caching.clear(); + installed = true; +} + +/** Places the same two translucent slabs within one section or across its boundary. */ +export async function createFixture(splitSections: boolean, reversePlacement: boolean): Promise<{ + scene: MineRenderScene; world: MineRenderWorld; targetZ: number; +}> { + installFixtureAssets(); + const scene = new MineRenderScene(); + const world = new MineRenderWorld(scene, { sectionMeshing: true }); + const redZ = splitSections ? 15 : 0; + const placements: { color: SlabColor; z: number }[] = [{ color: "red", z: redZ }, { color: "blue", z: redZ + 1 }]; + if (reversePlacement) placements.reverse(); + for (const { color, z } of placements) await world.setBlockAt(0, 0, z, { type: `${NAMESPACE}:${color}` }); + return { scene, world, targetZ: splitSections ? 248 : 8 }; +} diff --git a/apps/web/src/demo/transparency/index.html b/apps/web/src/demo/transparency/index.html new file mode 100644 index 00000000..32f7e49d --- /dev/null +++ b/apps/web/src/demo/transparency/index.html @@ -0,0 +1,45 @@ + + + + + + World transparency issue | MineRender + + + + +
+ All playgrounds +

World transparency issue

+

Two translucent slabs overlap: red at the lower Z coordinate, blue at the higher Z coordinate. Both textures have alpha 128/255. Each row shows the same camera view.

+

Compare one merged section with the same geometry placed across a section boundary. Separate section meshes receive Three.js object sorting. A missing color in the merged image exposes the ordering issue.

+
+ + Preparing fixture assets… +
+

Rendering…

+
+

This fixture uses MineRenderWorld with sectionMeshing: true, generated local textures, and no external asset requests. Reversing placement changes insertion order while preserving the blocks and camera views.

+
+ + + diff --git a/apps/web/src/demo/transparency/script.ts b/apps/web/src/demo/transparency/script.ts new file mode 100644 index 00000000..9f0dc0a7 --- /dev/null +++ b/apps/web/src/demo/transparency/script.ts @@ -0,0 +1,111 @@ +import { AssetLoader, Caching, shutdown } from "minerender"; +import * as THREE from "three"; +import { createFixture, installFixtureAssets } from "./fixture"; + +const button = document.getElementById("reverse") as HTMLButtonElement; +const status = document.getElementById("status")!; +const order = document.getElementById("order")!; +const renders = document.getElementById("renders")!; +let reversed = false; + +function capture(renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, label: string) { + renderer.render(scene, camera); + const gl = renderer.getContext(), pixel = new Uint8Array(4); + gl.readPixels(160, 120, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + if (gl.getError() !== gl.NO_ERROR) throw new Error("WebGL error while rendering " + label); + const figure = document.createElement("figure"), image = document.createElement("img"); + image.src = renderer.domElement.toDataURL(); + image.alt = label; + image.width = 320; image.height = 240; + const caption = document.createElement("figcaption"), sample = document.createElement("span"); + caption.textContent = label; + sample.className = "sample"; + sample.textContent = "Center pixel RGB: " + Array.from(pixel.slice(0, 3)).join(", "); + caption.append(sample); + figure.append(image, caption); + return { figure, pixel: Array.from(pixel) }; +} + +function meshes(scene: THREE.Scene) { + const result: { transparent: boolean; depthWrite: boolean; triangles: number }[] = []; + scene.traverse(object => { + if (object instanceof THREE.Mesh) { + const material = object.material as THREE.Material; + result.push({ transparent: material.transparent, depthWrite: material.depthWrite, + triangles: object.geometry.getIndex()!.count / 3 }); + } + }); + return result; +} + +async function run() { + button.disabled = true; + status.textContent = "Rendering…"; + order.textContent = reversed ? "Placement: blue, then red" : "Placement: red, then blue"; + const fixtures: Awaited>[] = []; + let renderer: THREE.WebGLRenderer | undefined; + try { + const merged = await createFixture(false, reversed); + fixtures.push(merged); + const separate = await createFixture(true, reversed); + fixtures.push(separate); + const mergedMeshes = meshes(merged.scene), separateMeshes = meshes(separate.scene); + if (mergedMeshes.length !== 1 || separateMeshes.length !== 2 + || [...mergedMeshes, ...separateMeshes].some(mesh => !mesh.transparent || mesh.triangles !== (mesh === mergedMeshes[0] ? 8 : 4))) { + throw new Error("Fixture did not produce one merged mesh and two separate translucent meshes"); + } + renderer = new THREE.WebGLRenderer({ alpha: false, antialias: false }); + renderer.setSize(320, 240); + renderer.setClearColor(0x000000, 1); + const camera = new THREE.OrthographicCamera(-16, 16, 12, -12, 0.1, 200); + const fragment = document.createDocumentFragment(); + const views: { side: string; merged: number[]; separate: number[]; difference: number }[] = []; + for (const direction of [1, -1]) { + const side = direction === 1 ? "From the blue side (+Z)" : "From the red side (−Z)"; + const section = document.createElement("section"), heading = document.createElement("h2"); + heading.textContent = side; + const pair = document.createElement("div"); + pair.className = "pair"; + const pixels: number[][] = []; + for (const [index, fixture] of fixtures.entries()) { + camera.position.set(0, 0, fixture.targetZ + direction * 80); + camera.lookAt(0, 0, fixture.targetZ); + camera.updateMatrixWorld(); + const result = capture(renderer, fixture.scene, camera, + index === 0 ? "One merged section · 1 mesh" : "Across a section boundary · 2 meshes"); + pair.append(result.figure); + pixels.push(result.pixel); + } + if (pixels[1][0] < 10 || pixels[1][2] < 10) throw new Error("Reference view is missing a translucent slab"); + const difference = Math.max(...pixels[0].map((value, index) => Math.abs(value - pixels[1][index]))); + views.push({ side, merged: pixels[0], separate: pixels[1], difference }); + section.append(heading, pair); + fragment.append(section); + } + renders.replaceChildren(fragment); + const mismatches = views.filter(view => view.difference > 4).length; + status.textContent = mismatches ? "PASS: fixture loaded. Issue reproduced in " + mismatches + " of 2 camera views." + : "PASS: fixture loaded. Both camera views match; the issue was not reproduced."; + return { reversed, mismatches, views, mergedMeshes, separateMeshes }; + } finally { + for (const fixture of fixtures) await fixture.world.clear(); + renderer?.dispose(); + button.disabled = false; + } +} + +function refresh() { + window["transparencyTest"] = run().catch(error => { + status.textContent = "FAIL: " + (error instanceof Error ? error.message : String(error)); + return { error: status.textContent }; + }); +} + +installFixtureAssets(); +button.addEventListener("click", () => { reversed = !reversed; refresh(); }); +window.addEventListener("pagehide", () => { + AssetLoader.removeSource("transparency-fixture"); + Caching.clear(); + shutdown(); +}, { once: true }); +refresh(); From 09d621f32e1396afb51ce305ffbaf11aa91126e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 13:34:00 +0200 Subject: [PATCH 2/6] Make the transparency demo scrollable and show missing panel detail --- apps/web/README.md | 2 +- apps/web/scripts/check-pages.mjs | 5 + apps/web/src/demo/transparency/fixture.ts | 26 ++- apps/web/src/demo/transparency/index.html | 63 ++++++-- apps/web/src/demo/transparency/script.ts | 188 ++++++++++++---------- 5 files changed, 174 insertions(+), 110 deletions(-) diff --git a/apps/web/README.md b/apps/web/README.md index b6dc9957..5d4dbd00 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -30,7 +30,7 @@ These paths are relative to the development server root: | `demo/custom_model/` | Java model JSON editor | | `demo/exports/` | Image, video, and 3D export of a small instanced scene | | `demo/materials/` | Canvas material shader regression check | -| `demo/transparency/` | Reproduces translucent face ordering in merged world sections from opposite camera views | +| `demo/transparency/` | Compares translucent face ordering with labeled panels, a shared camera, and separate-section reference | The sidebar from `src/playground/Playground.ts` provides presets, renderer and camera settings, Minecraft version / hosted root / resource-pack ZIP, image, video, and 3D exports, and a shareable link or JSON that restores the page state. Local files are not part of shared links and have to be selected again. diff --git a/apps/web/scripts/check-pages.mjs b/apps/web/scripts/check-pages.mjs index efe41cdf..d8b20e16 100644 --- a/apps/web/scripts/check-pages.mjs +++ b/apps/web/scripts/check-pages.mjs @@ -89,6 +89,11 @@ async function visit(url, name) { if (!await page.$("#viewport canvas")) { problems.add("Editor canvas missing"); } + } else if (await page.$("#camera-angle")) { + const result = await page.evaluate(() => window.transparencyTest); + status = await page.$eval("#status", element => element.textContent ?? ""); + if (result?.error) problems.add(result.error); + if (result?.mergedMeshes !== 1 || result?.separateMeshes !== 2) problems.add("Transparency fixture meshes missing"); } else { await new Promise(resolve => setTimeout(resolve, 3000)); status = (await page.evaluate(() => document.body.innerText)).split("\n").find(line => /PASS|FAIL/.test(line)) ?? "(no status)"; diff --git a/apps/web/src/demo/transparency/fixture.ts b/apps/web/src/demo/transparency/fixture.ts index eecea320..33a64644 100644 --- a/apps/web/src/demo/transparency/fixture.ts +++ b/apps/web/src/demo/transparency/fixture.ts @@ -6,20 +6,34 @@ type SlabColor = "red" | "blue"; function texture(color: SlabColor): TextureAsset { const canvas = document.createElement("canvas"); - canvas.width = canvas.height = 1; + canvas.width = canvas.height = 64; const context = canvas.getContext("2d")!; - const pixels = context.createImageData(1, 1); - pixels.data.set(color === "red" ? [255, 0, 0, 128] : [0, 0, 255, 128]); + const pixels = context.createImageData(64, 64); + for (let y = 0; y < 64; y++) { + for (let x = 0; x < 64; x++) { + const grid = x % 16 === 0 || y % 16 === 0; + const low = grid ? 40 : 0; + pixels.data.set(color === "red" ? [255, low, low, 128] : [low, low, 255, 128], (y * 64 + x) * 4); + } + } context.putImageData(pixels, 0, 0); + context.strokeStyle = color === "red" ? "#ffc4c4" : "#c4d4ff"; + context.lineWidth = 2; + context.strokeRect(1, 1, 62, 62); + context.fillStyle = "#ffffff"; + context.font = "bold 20px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(color === "red" ? "A" : "B", 32, 33); const encoded = atob(canvas.toDataURL("image/png").split(",")[1]); - return { width: 1, height: 1, type: "png", data: Uint8Array.from(encoded, character => character.charCodeAt(0)) }; + return { width: 64, height: 64, type: "png", data: Uint8Array.from(encoded, character => character.charCodeAt(0)) }; } function model(color: SlabColor): Model { return { textures: { surface: `${NAMESPACE}:block/${color}` }, - elements: [{ from: [1, 1, 7.9], to: [15, 15, 8.1], shade: false, - faces: { north: { texture: "#surface" }, south: { texture: "#surface" } } }] + elements: [{ from: [color === "red" ? -6 : 2, -2, 7.9], to: [color === "red" ? 14 : 22, 18, 8.1], shade: false, + faces: { north: { texture: "#surface", uv: [0, 0, 16, 16] }, south: { texture: "#surface", uv: [0, 0, 16, 16] } } }] }; } diff --git a/apps/web/src/demo/transparency/index.html b/apps/web/src/demo/transparency/index.html index 32f7e49d..4cee5b58 100644 --- a/apps/web/src/demo/transparency/index.html +++ b/apps/web/src/demo/transparency/index.html @@ -6,20 +6,23 @@ World transparency issue | MineRender