diff --git a/apps/web/README.md b/apps/web/README.md index 036455ad..5d4dbd00 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/` | 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..3c4bdeaa 100644 --- a/apps/web/scripts/check-pages.mjs +++ b/apps/web/scripts/check-pages.mjs @@ -89,6 +89,10 @@ 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("#summary", element => element.textContent ?? ""); + if (result?.error) problems.add(result.error); } 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 new file mode 100644 index 00000000..33a64644 --- /dev/null +++ b/apps/web/src/demo/transparency/fixture.ts @@ -0,0 +1,91 @@ +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 = 64; + const context = canvas.getContext("2d")!; + 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: 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: [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] } } }] + }; +} + +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..998e7cd0 --- /dev/null +++ b/apps/web/src/demo/transparency/index.html @@ -0,0 +1,75 @@ + + + + + + World transparency comparison | MineRender + + + + +
+ All playgrounds +

Compare translucent panels across world sections

+

Panel A is red; panel B is blue. Both are translucent fixture blocks. You should see the rear panel’s letter and grid through the translucent parts of the front panel.

+

Compare the panels merged into one world section with the same panels split across two sections, then move the shared camera to see their spacing.

+
+ + + 180° · A in front +
+
+ + + +
+

Loading the two panels…

+

+
+
+
Merged section — actualBoth panels share one mesh.
+
+
+
+
Separate sections — comparisonBoth panels contribute to the overlap in this fixture.
+
+
+
+

Start with A in front. Look for the blue B and its grid inside the red panel.

+
+ + Placement: A, then B +
+

Reversing placement keeps both panels in place. It changes their draw order.

+
+ Reproduction details +

This uses real MineRenderWorld placement with sectionMeshing: true. The comparison shifts the panels and camera together by 15 blocks, putting the panels on opposite sides of a section boundary. Geometry, textures, spacing, and materials stay the same.

+

Generated textures have alpha 128/255 with opaque letters and borders. All assets stay local. The floor grid provides depth context. This comparison demonstrates this two-panel case; it does not establish general correctness across section boundaries.

+

+
+
+ + + diff --git a/apps/web/src/demo/transparency/script.ts b/apps/web/src/demo/transparency/script.ts new file mode 100644 index 00000000..9088c8dc --- /dev/null +++ b/apps/web/src/demo/transparency/script.ts @@ -0,0 +1,152 @@ +import { AssetLoader, Caching, shutdown } from "minerender"; +import * as THREE from "three"; +import { createFixture, installFixtureAssets } from "./fixture"; + +type Fixture = Awaited>; +type View = Fixture & { grid: THREE.GridHelper }; +const width = 480, height = 360; +const RGB_TOLERANCE = 8; +const MIN_DIFFERING_PIXELS = 100; +const button = document.getElementById("reverse") as HTMLButtonElement; +const slider = document.getElementById("camera-angle") as HTMLInputElement; +const controls = Array.from(document.querySelectorAll("button, input")); +const status = document.getElementById("status")!; +const summary = document.getElementById("summary")!; +const order = document.getElementById("order")!; +const angleOutput = document.getElementById("angle")!; +const explanation = document.getElementById("explanation")!; +const measurements = document.getElementById("measurements")!; +let reversed = false; +let views: View[] = []; +const renderers: THREE.WebGLRenderer[] = []; + +function meshCount(scene: THREE.Scene): number { + let count = 0; + scene.traverse(object => { if (object instanceof THREE.Mesh) count++; }); + return count; +} + +function draw() { + if (views.length !== 2) return; + const angle = Number(slider.value), radians = angle * Math.PI / 180; + const front = angle > 90 ? "A" : "B", rear = front === "A" ? "B" : "A"; + angleOutput.textContent = angle + "° · " + (angle === 90 ? "edge-on" : front + " in front"); + const pixels: Uint8Array[] = []; + for (const [index, view] of views.entries()) { + const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 500); + camera.position.set(Math.sin(radians) * 80, 20, view.targetZ + Math.cos(radians) * 80); + camera.lookAt(0, 0, view.targetZ); + const renderer = renderers[index]; + renderer.render(view.scene, camera); + const gl = renderer.getContext(), data = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); + if (gl.getError() !== gl.NO_ERROR) throw new Error("WebGL error while drawing the panels"); + pixels.push(data); + } + let differentPixels = 0; + for (let i = 0; i < pixels[0].length; i += 4) { + if ([0, 1, 2].some(channel => Math.abs(pixels[0][i + channel] - pixels[1][i + channel]) > RGB_TOLERANCE)) differentPixels++; + } + const differs = differentPixels > MIN_DIFFERING_PIXELS; + status.textContent = differs ? "Issue visible: part of the rear panel is missing in the merged section." + : "These views match at this angle."; + explanation.textContent = differs ? "Look inside panel " + front + ": panel " + rear + + "’s letter and grid contribute in the comparison, but are hidden in the merged section." + : "Both views include the same panel detail. Moving the camera changes which panel is nearer; reversing placement changes the draw order."; + measurements.textContent = differentPixels.toLocaleString() + ` pixels differ between the views (RGB tolerance: ${RGB_TOLERANCE}/255).`; + return differentPixels; +} + +async function release(items: View[]) { + for (const view of items) { + await view.world.clear(); + view.grid.dispose(); + } +} + +async function refresh() { + controls.forEach(control => { control.disabled = true; }); + status.textContent = "Loading the two panels…"; + const next: View[] = []; + try { + for (const split of [false, true]) { + const fixture = await createFixture(split, reversed); + const grid = new THREE.GridHelper(64, 8, 0x71808c, 0x39434f); + grid.position.set(0, -12, fixture.targetZ); + fixture.scene.add(grid); + fixture.scene.background = new THREE.Color(0x151b23); + next.push({ ...fixture, grid }); + } + if (meshCount(next[0].scene) !== 1 || meshCount(next[1].scene) !== 2) { + throw new Error("Expected one merged mesh and two separate meshes"); + } + await release(views); + views = next; + order.textContent = reversed ? "Placement: B, then A" : "Placement: A, then B"; + return draw(); + } catch (error) { + if (views !== next) await release(next); + throw error; + } finally { + controls.forEach(control => { control.disabled = false; }); + } +} + +async function sweep() { + const originalAngle = slider.value, originalReversed = reversed; + const failures: { angle: number, reversed: boolean, differentPixels: number }[] = []; + for (const placementOrder of [false, true]) { + if (reversed !== placementOrder) { + reversed = placementOrder; + await refresh(); + } + for (const preset of document.querySelectorAll("[data-angle]")) { + slider.value = preset.dataset.angle!; + const differentPixels = draw()!; + if (differentPixels > MIN_DIFFERING_PIXELS) failures.push({ angle: Number(slider.value), reversed, differentPixels }); + } + } + slider.value = originalAngle; + reversed = originalReversed; + await refresh(); + summary.textContent = failures.length + ? "FAIL: the merged section hides panel detail at " + failures.map(failure => `${failure.angle}° (${failure.reversed ? "B, then A" : "A, then B"})`).join(", ") + "." + : "PASS: the merged section matches the separate sections at every preset in both placement orders."; + return { passed: failures.length === 0, failures }; +} + +function reload(runSweep = false) { + const loading = refresh().then(() => runSweep ? sweep() : undefined).catch(error => { + status.textContent = "Could not render the comparison: " + (error instanceof Error ? error.message : String(error)); + return { error: status.textContent }; + }); + if (runSweep) window["transparencyTest"] = loading; +} + +try { + installFixtureAssets(); + for (const id of ["merged", "separate"]) { + const renderer = new THREE.WebGLRenderer({ alpha: false, antialias: true }); + renderer.setSize(width, height, false); + renderer.domElement.setAttribute("aria-label", id === "merged" ? "Merged translucent panels" : "Separate translucent panels for comparison"); + document.getElementById(id)!.append(renderer.domElement); + renderers.push(renderer); + } + slider.addEventListener("input", draw); + for (const preset of document.querySelectorAll("[data-angle]")) { + preset.addEventListener("click", () => { slider.value = preset.dataset.angle!; draw(); }); + } + button.addEventListener("click", () => { reversed = !reversed; reload(); }); + window.addEventListener("pagehide", event => { + if (event.persisted) return; + void release(views); + for (const renderer of renderers) renderer.dispose(); + AssetLoader.removeSource("transparency-fixture"); + Caching.clear(); + shutdown(); + }); + reload(true); +} catch (error) { + status.textContent = "Could not start the comparison: " + (error instanceof Error ? error.message : String(error)); + window["transparencyTest"] = Promise.resolve({ error: status.textContent }); +}