diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java index 6a57698..956daae 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java @@ -19,6 +19,10 @@ public final class Element { public final double rotAngleRad; // radians public final boolean rescale; + // cos/sin of the element rotation (+angle: local -> block) and of its inverse (-angle: block -> local), + // precomputed for the intersector's hot path. + public final double rotCos, rotSin, invRotCos, invRotSin; + // Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this // world-space one. Intersectors hit the unrotated element with the inverse-rotated ray, so textures stay // bound to the model (vanilla); under uvlock its faces carry a UV transform keeping them world-aligned. @@ -59,6 +63,10 @@ public final class Element { this.rotAxis = rotAxis; this.rotAngleRad = rotAngleRad; this.rescale = rescale; + this.rotCos = Math.cos(rotAngleRad); + this.rotSin = Math.sin(rotAngleRad); + this.invRotCos = Math.cos(-rotAngleRad); + this.invRotSin = Math.sin(-rotAngleRad); } /** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java index 397fa11..6c068dd 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java @@ -10,7 +10,9 @@ import org.bukkit.util.Vector; /** * Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped * into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels - * are treated as holes (alpha cutout); partially transparent ones keep their alpha for the tracer to blend. The returned {@code t} is a world-space distance. + * are treated as holes (alpha cutout); partially transparent ones keep their alpha for the tracer to blend. + * The {@code t} is a world-space distance. The test writes into a caller-owned {@link Hit} and allocates + * nothing; {@link #toFaceHit} builds the result object only for the hit the caller finally keeps. */ public final class EntityIntersector { @@ -18,21 +20,48 @@ public final class EntityIntersector { private EntityIntersector() {} + /** Mutable per-thread result/scratch of {@link #intersect}; never shared between threads. */ + public static final class Hit { + public double t; + public int color; + int axis; + boolean neg; + final double[] o = new double[3], d = new double[3]; + } + /** * Nearest drawn face of the cube with {@code t > minT} (entry face first, else the far face); {@code minT} - * lets the tracer continue behind a translucent hit. + * lets the tracer continue behind a translucent hit. Cubes whose entry lies beyond {@code maxT} are + * rejected early (no texture lookup); a hit at exactly {@code maxT} is still reported. On a hit the face + * is left in {@code out}. */ - public static FaceHit intersect( - EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz, double minT) { - double[] o = cube.toLocal.apply(ox, oy, oz); - double[] d = cube.toLocal.applyLinear(dx, dy, dz); + public static boolean intersect( + EntityCube cube, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double minT, + double maxT, + Hit out) { + double[] r = cube.toLocal.r, tr = cube.toLocal.t; + double[] o = out.o, d = out.d; + o[0] = r[0] * ox + r[1] * oy + r[2] * oz + tr[0]; + o[1] = r[3] * ox + r[4] * oy + r[5] * oz + tr[1]; + o[2] = r[6] * ox + r[7] * oy + r[8] * oz + tr[2]; + d[0] = r[0] * dx + r[1] * dy + r[2] * dz; + d[1] = r[3] * dx + r[4] * dy + r[5] * dz; + d[2] = r[6] * dx + r[7] * dy + r[8] * dz; + double[] from = cube.from, to = cube.to; double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY; int inAxis = -1, outAxis = -1; boolean inNeg = false, outNeg = false; for (int a = 0; a < 3; a++) { if (Math.abs(d[a]) < EPS) { - if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null; + if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false; continue; } double inv = 1.0 / d[a]; @@ -40,8 +69,8 @@ public final class EntityIntersector { // max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box // (wing membranes, fins) both are equal and the comparison can't tell the sides apart. boolean forward = inv > 0; - double tNear = ((forward ? cube.from[a] : cube.to[a]) - o[a]) * inv; - double tFar = ((forward ? cube.to[a] : cube.from[a]) - o[a]) * inv; + double tNear = ((forward ? from[a] : to[a]) - o[a]) * inv; + double tFar = ((forward ? to[a] : from[a]) - o[a]) * inv; if (tNear > tmin) { tmin = tNear; inAxis = a; @@ -52,58 +81,54 @@ public final class EntityIntersector { outAxis = a; outNeg = !forward; } - if (tmin > tmax + EPS) return null; + if (tmin > tmax + EPS) return false; } double lo = Math.max(EPS, minT); - if (inAxis < 0 || tmax <= lo) return null; + if (inAxis < 0 || tmax <= lo || tmin > maxT) return false; // Vanilla draws entity models without back-face culling: a ray looking through a transparent // texel of the entry face still sees the inside of the far face (and both sides of a plane). - if (tmin > lo) { - FaceHit hit = sample(cube, o, d, tmin, inAxis, inNeg, ox, oy, oz, dx, dy, dz); - if (hit != null) return hit; - } - return outAxis < 0 ? null : sample(cube, o, d, tmax, outAxis, outNeg, ox, oy, oz, dx, dy, dz); + if (tmin > lo && sample(cube, o, d, tmin, inAxis, inNeg, out)) return true; + return outAxis >= 0 && sample(cube, o, d, tmax, outAxis, outNeg, out); } - private static FaceHit sample( - EntityCube cube, - double[] o, - double[] d, - double t, - int axis, - boolean neg, - double ox, - double oy, - double oz, - double dx, - double dy, - double dz) { - double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t; - Direction dir = - switch (axis) { - case 0 -> neg ? Direction.WEST : Direction.EAST; - case 1 -> neg ? Direction.DOWN : Direction.UP; - default -> neg ? Direction.NORTH : Direction.SOUTH; - }; + private static boolean sample(EntityCube cube, double[] o, double[] d, double t, int axis, boolean neg, Hit out) { + Direction dir = direction(axis, neg); Face face = cube.faces[dir.ordinal()]; - if (face == null) return null; - + if (face == null) return false; + double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t; double fx = frac(px, cube.from[0], cube.to[0]); double fy = frac(py, cube.from[1], cube.to[1]); double fz = frac(pz, cube.from[2], cube.to[2]); // Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv). - double[] st = ElementIntersector.faceUv(dir, fx, fy, fz); - double s = st[0], tt = st[1]; - int color = face.sample(s, tt); - if (!ColorUtil.opaque(color)) return null; + int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz)); + if (!ColorUtil.opaque(color)) return false; + out.t = t; + out.color = color; + out.axis = axis; + out.neg = neg; + return true; + } + /** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */ + public static FaceHit toFaceHit( + EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) { + double t = h.t; + Direction dir = direction(h.axis, h.neg); Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t); double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz); Vector normal = new Vector(n[0], n[1], n[2]).normalize(); // A far/back face is seen from inside: shade it as facing the viewer. if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1); - return new FaceHit(t, world, normal, color, -1, cube.additive); + return new FaceHit(t, world, normal, h.color, -1, cube.additive); + } + + private static Direction direction(int axis, boolean neg) { + return switch (axis) { + case 0 -> neg ? Direction.WEST : Direction.EAST; + case 1 -> neg ? Direction.DOWN : Direction.UP; + default -> neg ? Direction.NORTH : Direction.SOUTH; + }; } private static double frac(double v, double lo, double hi) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java index a66cab1..91a5f61 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java @@ -7,17 +7,63 @@ import java.util.ArrayList; import java.util.List; /** - * All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray, - * using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list - * and tested identically, so the raytracer needs no special case. Immutable after construction → safe - * for the parallel tracer. + * All baked entities (mobs + block-entities + decorations + objects) for one render. Provides the nearest hit + * along a ray. Every entity cube goes into one bounding volume hierarchy over the cubes' world AABBs (binned + * SAH, flat arrays), traversed front to back with the current best hit as the far bound, so a ray only tests + * the cubes near it instead of every entity — farms with hundreds of mobs/items cost about as much as a few. + * Near-ties (coplanar faces of different cubes) are decided by replaying the former linear scan over the few + * candidates, so images stay pixel-identical. Immutable after construction; the per-ray scratch is per thread + * → safe for the parallel tracer. */ public final class EntityScene { private static final double EPS = 1e-7; - private final List entities; + private static final int LEAF_SIZE = 4; + private static final int SAH_BINS = 16; + private static final int MAX_SAH_DEPTH = 40; private static volatile boolean warned; + private final List entities; + + // Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube). + private final EntityCube[] cubes; + private final int[] cubeRank; + private final double[] cubeBox; + + // BVH nodes: box (6 per node); inner nodes have left child = node + 1 and right child = nodeRight, + // leaves (nodeCount > 0) cover cubes[nodeStart .. nodeStart + nodeCount). + private double[] nodeBox; + private int[] nodeRight, nodeStart, nodeCount; + private int nodes; + private final int maxDepth; + + // Hits within this distance of the nearest one are candidates for the exact tie-break (pass 2 of + // nearestHit); far above double rounding at render distances, far below anything visible. + private static final double TIE_SLACK = 1e-6; + + /** Per-thread traversal scratch: stack and the candidate hits (reused, grown on demand). */ + private static final class Traversal { + int[] stack = new int[64]; + double[] stackT = new double[64]; + EntityIntersector.Hit[] cand = new EntityIntersector.Hit[0]; + int[] candCube = new int[8]; + int[] order = new int[8]; + + EntityIntersector.Hit candidate(int k) { + if (k == cand.length) { + int len = Math.max(8, k * 2); + EntityIntersector.Hit[] c = java.util.Arrays.copyOf(cand, len); + for (int i = k; i < len; i++) c[i] = new EntityIntersector.Hit(); + cand = c; + candCube = java.util.Arrays.copyOf(candCube, len); + order = java.util.Arrays.copyOf(order, len); + } + return cand[k]; + } + } + + private static final ThreadLocal TRAVERSAL = ThreadLocal.withInitial(Traversal::new); + public EntityScene( List states, CemBaker baker, @@ -32,6 +78,17 @@ public final class EntityScene { addAll(blockEntities, beBaker); addAll(decorations, decoBaker); addAll(objects, objectBaker); + + int n = 0; + for (RenderedEntity e : entities) n += e.cubes.size(); + EntityCube[] all = new EntityCube[n]; + int k = 0; + for (RenderedEntity e : entities) for (EntityCube c : e.cubes) all[k++] = c; + + cubes = new EntityCube[n]; + cubeRank = new int[n]; + cubeBox = new double[n * 6]; + maxDepth = build(all); } /** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */ @@ -61,52 +118,365 @@ public final class EntityScene { /** Nearest entity hit with {@code minT < t < maxT}, or null. */ public FaceHit nearestHit( double ox, double oy, double oz, double dx, double dy, double dz, double minT, double maxT) { - FaceHit best = null; - double bestT = maxT; - for (RenderedEntity e : entities) { - if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, minT, bestT)) continue; - for (EntityCube cube : e.cubes) { - if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, minT, bestT)) continue; - FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz, minT); - if (hit != null && hit.t() > EPS && hit.t() > minT && hit.t() < bestT) { - best = hit; - bestT = hit.t(); + if (nodes == 0) return null; + Traversal tr = TRAVERSAL.get(); + if (tr.stack.length < maxDepth + 2) { + tr.stack = new int[maxDepth + 2]; + tr.stackT = new double[maxDepth + 2]; + } + int[] stack = tr.stack; + double[] stackT = tr.stackT; + + // Per-axis slab setup, the same arithmetic as a per-box test (inv = 1/d; |d| < EPS = parallel). + boolean px = Math.abs(dx) < EPS, py = Math.abs(dy) < EPS, pz = Math.abs(dz) < EPS; + double ix = px ? 0 : 1.0 / dx, iy = py ? 0 : 1.0 / dy, iz = pz ? 0 : 1.0 / dz; + double t0 = Math.max(0, minT); + + // Pass 1 (BVH, front to back): every cube hit within TIE_SLACK of the nearest one becomes a candidate. + // The far bound stays 2·TIE_SLACK behind the nearest hit so rounding in the AABB tests can't drop one. + double nearest = Double.POSITIVE_INFINITY, bound = maxT; + int n = 0; + double[] nb = nodeBox, cb = cubeBox; + int sp = 0; + double rootT = entry(nb, 0, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound); + if (rootT != rootT) return null; + stack[sp] = 0; + stackT[sp++] = rootT; + while (sp > 0) { + int node = stack[--sp]; + if (stackT[sp] > bound) continue; // the nearest hit got closer since this node was queued + int count = nodeCount[node]; + if (count > 0) { + for (int i = nodeStart[node], end = i + count; i < end; i++) { + double ct = entry(cb, i, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound); + if (ct != ct) continue; // NaN: AABB missed + EntityIntersector.Hit hit = tr.candidate(n); + if (!EntityIntersector.intersect(cubes[i], ox, oy, oz, dx, dy, dz, minT, bound, hit)) continue; + double t = hit.t; + if (!(t < maxT) || t > nearest + TIE_SLACK) continue; + tr.candCube[n++] = i; + if (t < nearest) { + nearest = t; + bound = Math.min(maxT, t + 2 * TIE_SLACK); + } + } + } else { + int l = node + 1, r = nodeRight[node]; + double tl = entry(nb, l, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound); + double trr = entry(nb, r, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound); + boolean hl = tl == tl, hr = trr == trr; + if (hl && hr) { + // Near child on top of the stack: visited first, so the far one is usually culled. + if (tl <= trr) { + stack[sp] = r; + stackT[sp++] = trr; + stack[sp] = l; + stackT[sp++] = tl; + } else { + stack[sp] = l; + stackT[sp++] = tl; + stack[sp] = r; + stackT[sp++] = trr; + } + } else if (hl) { + stack[sp] = l; + stackT[sp++] = tl; + } else if (hr) { + stack[sp] = r; + stackT[sp++] = trr; } } } - return best; + if (n == 0) return null; + + // Pass 2: replay the former linear scan over the near candidates in bake order — a cube counts only if + // its AABB passes with the best t so far as far bound and it is strictly nearer. Coplanar faces of + // different cubes (equal t up to rounding) thus resolve exactly as before: pixel-identical images. + int[] candCube = tr.candCube; + int[] order = tr.order; + int m = 0; + for (int k = 0; k < n; k++) { + if (tr.cand[k].t > nearest + TIE_SLACK) continue; + // Insertion by bake rank (a handful of candidates at most). + int rank = cubeRank[candCube[k]], j = m++; + while (j > 0 && cubeRank[candCube[order[j - 1]]] > rank) { + order[j] = order[j - 1]; + j--; + } + order[j] = k; + } + double bestT = maxT; + int best = -1; + for (int j = 0; j < m; j++) { + int k = order[j]; + double t = tr.cand[k].t; + double e = entry(cb, candCube[k], ox, oy, oz, ix, iy, iz, px, py, pz, t0, bestT); + if (e == e && t < bestT) { + bestT = t; + best = k; + } + } + return EntityIntersector.toFaceHit(cubes[candCube[best]], tr.cand[best], ox, oy, oz, dx, dy, dz); } - private static boolean rayAabb( - double[] min, - double[] max, + /** + * Slab test of box {@code i} of {@code boxes} (6 doubles per box) against the ray within {@code [t0, tMax]}; + * returns the entry t, or NaN on a miss. Same comparisons as the former per-entity/per-cube AABB check + * (a box touching the interval at {@code tMax} still counts). + */ + private static double entry( + double[] boxes, + int i, double ox, double oy, double oz, - double dx, - double dy, - double dz, - double minT, - double maxT) { - double tmin = Math.max(0, minT), tmax = maxT; - double[] o = {ox, oy, oz}, d = {dx, dy, dz}; - for (int a = 0; a < 3; a++) { - if (Math.abs(d[a]) < EPS) { - if (o[a] < min[a] || o[a] > max[a]) return false; - } else { - double inv = 1.0 / d[a]; - double t1 = (min[a] - o[a]) * inv; - double t2 = (max[a] - o[a]) * inv; - if (t1 > t2) { - double tmp = t1; - t1 = t2; - t2 = tmp; - } - if (t1 > tmin) tmin = t1; - if (t2 < tmax) tmax = t2; - if (tmin > tmax) return false; + double ix, + double iy, + double iz, + boolean px, + boolean py, + boolean pz, + double t0, + double tMax) { + int b = i * 6; + double tmin = t0, tmax = tMax; + if (px) { + if (ox < boxes[b] || ox > boxes[b + 3]) return Double.NaN; + } else { + double t1 = (boxes[b] - ox) * ix, t2 = (boxes[b + 3] - ox) * ix; + if (t1 > t2) { + double tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return Double.NaN; + } + if (py) { + if (oy < boxes[b + 1] || oy > boxes[b + 4]) return Double.NaN; + } else { + double t1 = (boxes[b + 1] - oy) * iy, t2 = (boxes[b + 4] - oy) * iy; + if (t1 > t2) { + double tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return Double.NaN; + } + if (pz) { + if (oz < boxes[b + 2] || oz > boxes[b + 5]) return Double.NaN; + } else { + double t1 = (boxes[b + 2] - oz) * iz, t2 = (boxes[b + 5] - oz) * iz; + if (t1 > t2) { + double tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return Double.NaN; + } + return tmin; + } + + // ---- BVH construction (binned SAH over the cube AABB centroids) ---- + + private double[] centroid; // 3 per original cube + private double[] srcBox; // 6 per original cube + private int[] perm; // original cube indices, partitioned in place + private int filled; // cubes written in leaf order + + private int build(EntityCube[] all) { + int n = all.length; + if (n == 0) { + nodeBox = new double[0]; + nodeRight = nodeStart = nodeCount = new int[0]; + return 0; + } + srcBox = new double[n * 6]; + centroid = new double[n * 3]; + perm = new int[n]; + for (int i = 0; i < n; i++) { + EntityCube c = all[i]; + for (int a = 0; a < 3; a++) { + srcBox[i * 6 + a] = c.aabbMin[a]; + srcBox[i * 6 + 3 + a] = c.aabbMax[a]; + centroid[i * 3 + a] = (c.aabbMin[a] + c.aabbMax[a]) * 0.5; + } + perm[i] = i; + } + int cap = 2 * n; + nodeBox = new double[cap * 6]; + nodeRight = new int[cap]; + nodeStart = new int[cap]; + nodeCount = new int[cap]; + int depth = buildNode(all, 0, n, 0); + srcBox = centroid = null; + perm = null; + return depth; + } + + /** Builds the subtree over perm[lo, hi); returns its depth. */ + private int buildNode(EntityCube[] all, int lo, int hi, int depth) { + int node = nodes++; + int nb = node * 6; + nodeBox[nb] = nodeBox[nb + 1] = nodeBox[nb + 2] = Double.MAX_VALUE; + nodeBox[nb + 3] = nodeBox[nb + 4] = nodeBox[nb + 5] = -Double.MAX_VALUE; + double[] cmin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; + double[] cmax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; + for (int i = lo; i < hi; i++) { + int c = perm[i]; + for (int a = 0; a < 3; a++) { + nodeBox[nb + a] = Math.min(nodeBox[nb + a], srcBox[c * 6 + a]); + nodeBox[nb + 3 + a] = Math.max(nodeBox[nb + 3 + a], srcBox[c * 6 + 3 + a]); + cmin[a] = Math.min(cmin[a], centroid[c * 3 + a]); + cmax[a] = Math.max(cmax[a], centroid[c * 3 + a]); } } - return true; + int count = hi - lo; + int axis = 0; + for (int a = 1; a < 3; a++) if (cmax[a] - cmin[a] > cmax[axis] - cmin[axis]) axis = a; + double extent = cmax[axis] - cmin[axis]; + int mid; + if (count <= LEAF_SIZE || !(extent > 1e-9)) { + mid = -1; + } else if (depth >= MAX_SAH_DEPTH) { + // Pathological centroid distributions can make SAH splits very lopsided: median keeps it bounded. + sortByCentroid(lo, hi, axis); + mid = lo + count / 2; + } else { + mid = sahSplit(nb, lo, hi, axis, cmin[axis], extent); + } + if (mid < 0) { + // Leaf: cubes in bake order inside the leaf (ascending original index). + java.util.Arrays.sort(perm, lo, hi); + nodeStart[node] = filled; + nodeCount[node] = count; + for (int i = lo; i < hi; i++) { + int c = perm[i]; + cubes[filled] = all[c]; + cubeRank[filled] = c; + System.arraycopy(srcBox, c * 6, cubeBox, filled * 6, 6); + filled++; + } + return depth; + } + nodeCount[node] = 0; + int dl = buildNode(all, lo, mid, depth + 1); + nodeRight[node] = nodes; + int dr = buildNode(all, mid, hi, depth + 1); + return Math.max(dl, dr); + } + + /** + * Partitions perm[lo, hi) along {@code axis} at the cheapest of {@link #SAH_BINS}-1 bin boundaries + * (surface area heuristic); falls back to the median when every boundary leaves one side empty. Returns + * the split index, or -1 when a leaf is cheaper. + */ + private int sahSplit(int nb, int lo, int hi, int axis, double cmin, double extent) { + int[] binCount = new int[SAH_BINS]; + double[] binBox = new double[SAH_BINS * 6]; + for (int b = 0; b < SAH_BINS; b++) { + for (int a = 0; a < 3; a++) { + binBox[b * 6 + a] = Double.MAX_VALUE; + binBox[b * 6 + 3 + a] = -Double.MAX_VALUE; + } + } + double scale = SAH_BINS / extent; + for (int i = lo; i < hi; i++) { + int c = perm[i]; + int b = bin(centroid[c * 3 + axis], cmin, scale); + binCount[b]++; + for (int a = 0; a < 3; a++) { + binBox[b * 6 + a] = Math.min(binBox[b * 6 + a], srcBox[c * 6 + a]); + binBox[b * 6 + 3 + a] = Math.max(binBox[b * 6 + 3 + a], srcBox[c * 6 + 3 + a]); + } + } + // Sweep: cost(split after bin s) = area(left)·nLeft + area(right)·nRight. + double[] leftArea = new double[SAH_BINS]; + int[] leftCount = new int[SAH_BINS]; + double[] acc = { + Double.MAX_VALUE, + Double.MAX_VALUE, + Double.MAX_VALUE, + -Double.MAX_VALUE, + -Double.MAX_VALUE, + -Double.MAX_VALUE + }; + int cnt = 0; + for (int b = 0; b < SAH_BINS - 1; b++) { + grow(acc, binBox, b); + cnt += binCount[b]; + leftArea[b] = area(acc); + leftCount[b] = cnt; + } + acc[0] = acc[1] = acc[2] = Double.MAX_VALUE; + acc[3] = acc[4] = acc[5] = -Double.MAX_VALUE; + cnt = 0; + double bestCost = Double.POSITIVE_INFINITY; + int bestSplit = -1; + for (int b = SAH_BINS - 1; b >= 1; b--) { + grow(acc, binBox, b); + cnt += binCount[b]; + int left = leftCount[b - 1]; + if (left == 0 || cnt == 0) continue; + double cost = leftArea[b - 1] * left + area(acc) * cnt; + if (cost < bestCost) { + bestCost = cost; + bestSplit = b; + } + } + int count = hi - lo; + if (bestSplit < 0) { + // All centroids in one bin (should not happen with extent > 0): median split. + sortByCentroid(lo, hi, axis); + return lo + count / 2; + } + // A leaf costs area(node)·count (one cube test per cube), a split one node visit more. + double nodeArea = area(nodeBox, nb); + if (count <= 16 && bestCost + nodeArea >= nodeArea * count) return -1; + int i = lo, j = hi - 1; + while (i <= j) { + if (bin(centroid[perm[i] * 3 + axis], cmin, scale) < bestSplit) { + i++; + } else { + int tmp = perm[i]; + perm[i] = perm[j]; + perm[j--] = tmp; + } + } + return i; + } + + private static int bin(double c, double cmin, double scale) { + int b = (int) ((c - cmin) * scale); + return b < 0 ? 0 : Math.min(b, SAH_BINS - 1); + } + + private void sortByCentroid(int lo, int hi, int axis) { + Integer[] tmp = new Integer[hi - lo]; + for (int i = lo; i < hi; i++) tmp[i - lo] = perm[i]; + java.util.Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis])); + for (int i = lo; i < hi; i++) perm[i] = tmp[i - lo]; + } + + private static void grow(double[] acc, double[] boxes, int b) { + for (int a = 0; a < 3; a++) { + acc[a] = Math.min(acc[a], boxes[b * 6 + a]); + acc[3 + a] = Math.max(acc[3 + a], boxes[b * 6 + 3 + a]); + } + } + + private static double area(double[] box) { + return area(box, 0); + } + + private static double area(double[] box, int o) { + double x = box[o + 3] - box[o], y = box[o + 4] - box[o + 1], z = box[o + 5] - box[o + 2]; + if (!(x >= 0) || !(y >= 0) || !(z >= 0)) return 0; + return 2 * (x * y + y * z + z * x); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java index 52a2b16..524880b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java @@ -5,7 +5,8 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; -import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import org.bukkit.util.Vector; @@ -22,6 +23,50 @@ public final class ElementIntersector { private ElementIntersector() {} + /** + * Per-thread scratch of one {@link #intersect} call: the candidate faces as parallel primitive arrays, so + * the hot path allocates nothing per element test (the tracer runs rays in parallel, hence per thread). + */ + private static final class Scratch { + int n; + Element[] element = new Element[16]; + double[] t = new double[16]; + Direction[] dir = new Direction[16]; + double[] s = new double[16]; + double[] t2 = new double[16]; + double[] nx = new double[16], ny = new double[16], nz = new double[16]; + int[] order = new int[16]; + // Candidate indices to sort; pre-boxed (cached Integers) so the sort allocates nothing. + Integer[] idx = new Integer[16]; + final double[] o = new double[3], d = new double[3], v = new double[3]; + // Result of the last slab test. + double hitT, hitS, hitT2, hitNx, hitNy, hitNz; + Direction hitDir; + + // Same ordering as before (depth; coplanar within 1e-4: later elements first) over candidate indices. + // Sorted with the same TimSort as the former List.sort, so ties resolve identically. + final Comparator byDepth = (a, b) -> { + if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]); + return Integer.compare(order[b], order[a]); + }; + + void grow() { + int len = element.length * 2; + element = Arrays.copyOf(element, len); + t = Arrays.copyOf(t, len); + dir = Arrays.copyOf(dir, len); + s = Arrays.copyOf(s, len); + t2 = Arrays.copyOf(t2, len); + nx = Arrays.copyOf(nx, len); + ny = Arrays.copyOf(ny, len); + nz = Arrays.copyOf(nz, len); + order = Arrays.copyOf(order, len); + idx = new Integer[len]; + } + } + + private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(Scratch::new); + /** * @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner) * @param dx,dy,dz ray direction (need not be normalized) @@ -40,72 +85,88 @@ public final class ElementIntersector { int by, int bz, double minT) { - List candidates = new ArrayList<>(model.elements.size()); - for (int i = 0; i < model.elements.size(); i++) { - Element element = model.elements.get(i); - Candidate c; + List elements = model.elements; + int count = elements.size(); + if (count == 0) return null; + Scratch sc = SCRATCH.get(); + sc.n = 0; + double[] v = sc.v; + for (int i = 0; i < count; i++) { + Element element = elements.get(i); + boolean hit; + Element e; if (element.unrotated != null) { // Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid // transform), so faces and UVs stay bound to the model like in vanilla (uvlock: see Face#uvLock). - Element m = element.unrotated; - double[] mo = Element.unrotate(ox, oy, oz, element.xSteps, element.ySteps, true); - double[] md = Element.unrotate(dx, dy, dz, element.xSteps, element.ySteps, false); - c = m.isAxisAligned() - ? intersectAabb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]) - : intersectObb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]); - if (c != null) { - double[] n = Element.rotate( - c.normal[0], c.normal[1], c.normal[2], element.xSteps, element.ySteps, false); - c = new Candidate(c.element, c.t, c.dir, c.s, c.t2, n, 0); + e = element.unrotated; + unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true); + double mox = v[0], moy = v[1], moz = v[2]; + unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false); + hit = e.isAxisAligned() + ? slab(sc, mox, moy, moz, v[0], v[1], v[2], e.from, e.to, null) + : intersectObb(sc, e, mox, moy, moz, v[0], v[1], v[2]); + if (hit) { + rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false); + sc.hitNx = v[0]; + sc.hitNy = v[1]; + sc.hitNz = v[2]; } } else { - c = element.isAxisAligned() - ? intersectAabb(element, ox, oy, oz, dx, dy, dz) - : intersectObb(element, ox, oy, oz, dx, dy, dz); + e = element; + hit = e.isAxisAligned() + ? slab(sc, ox, oy, oz, dx, dy, dz, e.from, e.to, null) + : intersectObb(sc, e, ox, oy, oz, dx, dy, dz); } - if (c != null && c.t > minT) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i)); + if (!hit || !(sc.hitT > minT)) continue; + int k = sc.n; + if (k == sc.element.length) sc.grow(); + sc.element[k] = e; + sc.t[k] = sc.hitT; + sc.dir[k] = sc.hitDir; + sc.s[k] = sc.hitS; + sc.t2[k] = sc.hitT2; + sc.nx[k] = sc.hitNx; + sc.ny[k] = sc.hitNy; + sc.nz[k] = sc.hitNz; + sc.order[k] = i; + sc.n = k + 1; } - if (candidates.isEmpty()) return null; + int n = sc.n; + if (n == 0) return null; + Integer[] idx = sc.idx; + for (int k = 0; k < n; k++) idx[k] = k; // autoboxing hits the Integer cache for k < 128 // Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's // draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face. - candidates.sort((a, b) -> { - if (Math.abs(a.t - b.t) > 1e-4) return Double.compare(a.t, b.t); - return Integer.compare(b.order, a.order); - }); + if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth); - for (Candidate c : candidates) { - Face face = c.element.faces[c.dir.ordinal()]; + for (int j = 0; j < n; j++) { + int k = idx[j]; + Face face = sc.element[k].faces[sc.dir[k].ordinal()]; if (face == null) continue; - int color = face.sample(c.s, c.t2); + int color = face.sample(sc.s[k], sc.t2[k]); if (!ColorUtil.opaque(color)) continue; - Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t); - Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]); - return new FaceHit(c.t, world, normal, color, face.tintIndex, false); + double t = sc.t[k]; + Vector world = new Vector(bx + ox + dx * t, by + oy + dy * t, bz + oz + dz * t); + Vector normal = new Vector(sc.nx[k], sc.ny[k], sc.nz[k]); + return new FaceHit(t, world, normal, color, face.tintIndex, false); } return null; } - private record Candidate( - Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {} - - private static Candidate intersectAabb( - Element e, double ox, double oy, double oz, double dx, double dy, double dz) { - return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null); - } - /** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */ - private static Candidate intersectObb(Element e, double ox, double oy, double oz, double dx, double dy, double dz) { - double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad); - o[0] += e.rotOrigin[0]; - o[1] += e.rotOrigin[1]; - o[2] += e.rotOrigin[2]; - double[] d = rotate(dx, dy, dz, e.rotAxis, -e.rotAngleRad); - return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e); + private static boolean intersectObb( + Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) { + double[] v = sc.v; + rotate(v, ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, e.invRotCos, e.invRotSin); + double lox = v[0] + e.rotOrigin[0], loy = v[1] + e.rotOrigin[1], loz = v[2] + e.rotOrigin[2]; + rotate(v, dx, dy, dz, e.rotAxis, e.invRotCos, e.invRotSin); + return slab(sc, lox, loy, loz, v[0], v[1], v[2], e.from, e.to, e); } - private static Candidate slab( - Element e, + /** Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. */ + private static boolean slab( + Scratch sc, double ox, double oy, double oz, @@ -120,11 +181,16 @@ public final class ElementIntersector { int axis = -1; boolean negFace = false; // entered through the low-coordinate face - double[] o = {ox, oy, oz}; - double[] d = {dx, dy, dz}; + double[] o = sc.o, d = sc.d; + o[0] = ox; + o[1] = oy; + o[2] = oz; + d[0] = dx; + d[1] = dy; + d[2] = dz; for (int a = 0; a < 3; a++) { if (Math.abs(d[a]) < EPS) { - if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return null; + if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false; continue; } double inv = 1.0 / d[a]; @@ -144,15 +210,15 @@ public final class ElementIntersector { negFace = neg; } if (t2 < tmax) tmax = t2; - if (tmin > tmax) return null; + if (tmin > tmax) return false; } - if (axis < 0) return null; + if (axis < 0) return false; double tEntry = tmin; if (tEntry < EPS) { // origin inside the box (e.g. camera within a block): use exit point instead tEntry = tmax; - if (tEntry < EPS) return null; + if (tEntry < EPS) return false; } double px = o[0] + d[0] * tEntry; @@ -160,35 +226,51 @@ public final class ElementIntersector { double pz = o[2] + d[2] * tEntry; Direction dir = faceFor(axis, negFace); - double[] normal = {dir.nx, dir.ny, dir.nz}; if (obb != null) { // rotate the normal back into block space - normal = rotate(normal[0], normal[1], normal[2], obb.rotAxis, obb.rotAngleRad); + double[] v = sc.v; + rotate(v, dir.nx, dir.ny, dir.nz, obb.rotAxis, obb.rotCos, obb.rotSin); + sc.hitNx = v[0]; + sc.hitNy = v[1]; + sc.hitNz = v[2]; + } else { + sc.hitNx = dir.nx; + sc.hitNy = dir.ny; + sc.hitNz = dir.nz; } double fracX = frac(px, from[0], to[0]); double fracY = frac(py, from[1], to[1]); double fracZ = frac(pz, from[2], to[2]); - double[] st = faceUv(dir, fracX, fracY, fracZ); - double s = st[0], t = st[1]; - return new Candidate(e, tEntry, dir, s, t, normal, 0); + sc.hitT = tEntry; + sc.hitDir = dir; + sc.hitS = faceS(dir, fracX, fracY, fracZ); + sc.hitT2 = faceT(dir, fracY, fracZ); + return true; } /** - * Face-local (s, t) in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's - * {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with these, so north/east + * Face-local horizontal coordinate s in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's + * {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with (s, t), so north/east * faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards * north. Shared with the entity intersector (entity box-UV uses the same orientation). */ - public static double[] faceUv(Direction dir, double fx, double fy, double fz) { + public static double faceS(Direction dir, double fx, double fy, double fz) { return switch (dir) { - case UP -> new double[] {fx, fz}; - case DOWN -> new double[] {fx, 1 - fz}; - case NORTH -> new double[] {1 - fx, 1 - fy}; - case SOUTH -> new double[] {fx, 1 - fy}; - case WEST -> new double[] {fz, 1 - fy}; - case EAST -> new double[] {1 - fz, 1 - fy}; + case UP, DOWN, SOUTH -> fx; + case NORTH -> 1 - fx; + case WEST -> fz; + case EAST -> 1 - fz; + }; + } + + /** Face-local vertical coordinate t in 0..1 (see {@link #faceS}); t does not depend on fx. */ + public static double faceT(Direction dir, double fy, double fz) { + return switch (dir) { + case UP -> fz; + case DOWN -> 1 - fz; + default -> 1 - fy; }; } @@ -207,14 +289,49 @@ public final class ElementIntersector { return f < 0 ? 0 : Math.min(f, 1); } - /** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by angle radians. */ - private static double[] rotate(double x, double y, double z, int axis, double angle) { - double cos = Math.cos(angle); - double sin = Math.sin(angle); - return switch (axis) { - case 0 -> new double[] {x, y * cos - z * sin, y * sin + z * cos}; - case 1 -> new double[] {x * cos + z * sin, y, -x * sin + z * cos}; - default -> new double[] {x * cos - y * sin, x * sin + y * cos, z}; - }; + /** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by the angle with the given cos/sin, into out. */ + private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) { + switch (axis) { + case 0 -> { + out[0] = x; + out[1] = y * cos - z * sin; + out[2] = y * sin + z * cos; + } + case 1 -> { + out[0] = x * cos + z * sin; + out[1] = y; + out[2] = -x * sin + z * cos; + } + default -> { + out[0] = x * cos - y * sin; + out[1] = x * sin + y * cos; + out[2] = z; + } + } + } + + /** {@link Element#rotate} into out (same arithmetic, no allocation). */ + private static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) { + double c = point ? 0.5 : 0; + double dx = x - c, dy = y - c, dz = z - c; + for (int i = 0; i < xSteps; i++) { + double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy) + dy = ny; + dz = nz; + } + for (int i = 0; i < ySteps; i++) { + double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx) + dx = nx; + dz = nz; + } + out[0] = dx + c; + out[1] = dy + c; + out[2] = dz + c; + } + + /** {@link Element#unrotate} into out (same arithmetic, no allocation). */ + private static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) { + rotate(out, x, y, z, 0, (4 - ySteps) % 4, point); + rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point); } } diff --git a/tools/EntityBenchmark.java b/tools/EntityBenchmark.java new file mode 100644 index 0000000..c29264a --- /dev/null +++ b/tools/EntityBenchmark.java @@ -0,0 +1,195 @@ +import eu.mhsl.minecraft.pixelpics.assets.*; +import eu.mhsl.minecraft.pixelpics.render.entity.*; +import eu.mhsl.minecraft.pixelpics.render.render.*; +import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; +import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; +import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.bukkit.util.Vector; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.List; +import java.util.logging.Logger; + +/** + * Reproducible render benchmark (no server): farm scenes with hundreds of mobs / dropped items on a block + * floor plus a "normal" scene (few mobs, translucent + additive layers). Renders every scene {@code -Druns} + * times (after {@code -Dwarmup} runs), prints the median wall time and writes one PNG per scene to + * {@code -Dout} (default ~/Dokumente/PixelPics-Entity-Renders/bench) for before/after pixel diffs. + * Args: scene names to run (default all): farm_mobs, farm_empty (same view, no entities), farm_mobs_close, + * farm_items, normal. {@code -Djava.util.concurrent.ForkJoinPool.common.parallelism=N} pins the thread count. + */ +public class EntityBenchmark { + + static final String ROOT = "/home/elias/Dokumente/mcTestServer/plugins/PixelPics"; + static final int W = 320, H = 240, SSAA = 3; + + record Scene(String name, Vector cam, Vector look, List mobs, List objects) {} + + public static void main(String[] args) throws Exception { + Logger log = Logger.getLogger("bench"); + ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow(); + AssetReader reader = new AssetReader(pack); + TextureCache textures = new TextureCache(pack); + BlockModelRegistry registry = new BlockModelRegistry(reader, textures); + BiomeTintProvider tint = new BiomeTintProvider(textures); + var geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader(); + geo.load(new java.io.FileInputStream("src/main/resources/cem/cem_template_models.json"), log); + geo.load(new java.io.FileInputStream("src/main/resources/cem/vanilla_entity_models.json"), log); + var font = eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log); + SkinCache skins = new SkinCache(); + var baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins); + var beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins, font); + DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); + + WorldSnapshot world = farmWorld(); + SkyContext sky = new SkyContext(6000, 0, 6000); + int runs = Integer.getInteger("runs", 5), warmup = Integer.getInteger("warmup", 2); + File outDir = new File(System.getProperty("out", "/home/elias/Dokumente/PixelPics-Entity-Renders/bench")); + outDir.mkdirs(); + Set only = new HashSet<>(Arrays.asList(args)); + + for (Scene sc : scenes()) { + if (!only.isEmpty() && !only.contains(sc.name)) continue; + Location loc = new Location(null, sc.cam.getX(), sc.cam.getY(), sc.cam.getZ()); + loc.setDirection(sc.look.clone().subtract(sc.cam)); + List rayMap = EntityTestRender.buildRayMap(loc, W * SSAA, H * SSAA); + RenderJob job = new RenderJob(world, rayMap, sc.cam, W, H, sky, sc.mobs, List.of(), List.of(), sc.objects); + BufferedImage img = null; + for (int i = 0; i < warmup; i++) img = renderer.execute(job); + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + long t0 = System.nanoTime(); + img = renderer.execute(job); + times[i] = System.nanoTime() - t0; + } + Arrays.sort(times); + ImageIO.write(img, "png", new File(outDir, sc.name + ".png")); + System.out.printf("BENCH %-16s mobs=%4d objects=%4d median %7.1f ms min %7.1f ms%n", sc.name, + sc.mobs.size(), sc.objects.size(), times[runs / 2] / 1e6, times[0] / 1e6); + } + } + + static List scenes() { + List out = new ArrayList<>(); + // Mob farm: 400 animals in a 20x20 pen (1.4 block spacing), seen from an elevated corner. + List farm = new ArrayList<>(); + Random r = new Random(42); + String[] types = {"cow", "sheep", "chicken", "pig"}; + for (int i = 0; i < 20; i++) { + for (int j = 0; j < 20; j++) { + String type = types[r.nextInt(types.length)]; + farm.add(mob(type, i * 1.4 + r.nextDouble() * 0.4, 1, j * 1.4 + r.nextDouble() * 0.4, + r.nextFloat() * 360, r.nextInt(5) == 0)); + } + } + out.add(new Scene("farm_mobs", new Vector(-6, 14, -6), new Vector(14, 1, 14), farm, List.of())); + // Same view without entities: farm_mobs minus this = the entity share of the render time. + out.add(new Scene("farm_empty", new Vector(-6, 14, -6), new Vector(14, 1, 14), List.of(), List.of())); + out.add(new Scene("farm_mobs_close", new Vector(3, 4.5, -3), new Vector(10, 1, 8), farm, List.of())); + + // Item farm: 500 dropped items (flat sprites + block items) piled on a 10x10 collection area. + List items = new ArrayList<>(); + String[] flat = {"wheat", "carrot", "egg", "bone", "rotten_flesh", "string", "gunpowder", "iron_ingot"}; + Material[] blocks = {Material.COBBLESTONE, Material.OAK_LOG, Material.PUMPKIN}; + String[] blockState = {null, "axis=y", null}; + for (int i = 0; i < 500; i++) { + double x = 2 + r.nextDouble() * 10, z = 2 + r.nextDouble() * 10, y = 1.1 + r.nextDouble() * 0.3; + Affine a = Affine.translation(x, y, z).mul(Affine.rotY(r.nextDouble() * Math.PI * 2)).mul(Affine.scale(0.5)); + ItemSpec spec; + if (r.nextInt(4) == 0) { + int b = r.nextInt(blocks.length); + spec = new ItemSpec(blocks[b].getKey().getKey(), ObjectTestRender.block(blocks[b], blockState[b]), null, false); + } else { + spec = new ItemSpec(flat[r.nextInt(flat.length)], null, null, false); + } + items.add(ObjectState.item(a, spec, "ground", true)); + } + out.add(new Scene("farm_items", new Vector(0, 7, -1), new Vector(7, 1, 7), List.of(), items)); + + // Normal scene: a handful of mobs incl. translucent (slime) and additive (charged creeper) layers, + // a text display with the default translucent background and two items. + List few = new ArrayList<>(); + few.add(mob("cow", 3, 1, 3, 30, false)); + few.add(mob("sheep", 5, 1, 4, 200, false)); + few.add(mob("villager", 4, 1, 6, 160, false)); + few.add(mob("zombie", 6.5, 1, 6, 220, false)); + few.add(mob("slime", 2, 1, 5.5, 120, false)); + EntityState creeper = mob("creeper", 7, 1, 3.5, 250, false); + few.add(withPowered(creeper)); + few.add(mob("chicken", 3.5, 1, 1.5, 90, false)); + List objs = new ArrayList<>(); + objs.add(ObjectState.item(Affine.translation(4.5, 1.2, 2).mul(Affine.scale(0.5)), new ItemSpec("apple", null, null, false), "ground", true)); + objs.add(ObjectState.text(Affine.translation(4.5, 3.2, 4).mul(Affine.rotY(Math.PI)).mul(Affine.scale(0.025)), + List.of("PixelPics bench"), 200, 0xFFFFFFFF, 0x40000000, true)); + out.add(new Scene("normal", new Vector(0, 3.5, -2.5), new Vector(4.5, 1.5, 4), few, objs)); + return out; + } + + static EntityState mob(String type, double x, double y, double z, float yaw, boolean baby) { + int tint = type.equals("sheep") ? 0xFFF9FFFE : 0; + return new EntityState(type, x, y, z, yaw, baby, 0.8, 1.0, + false, null, false, variant(type), tint, 1.0, + type.equals("villager") ? "farmer" : null, type.equals("villager") ? 2 : 0, + null, false, false, false, null, null, + false, 0, 0, 0, false, null, null, null, 0, false, null); + } + + /** The variant the game always supplies (cow/pig/chicken have no texture without one). */ + static String variant(String type) { + return switch (type) { + case "villager" -> "plains"; + case "cow", "pig", "chicken" -> "temperate"; + default -> null; + }; + } + + static EntityState withPowered(EntityState s) { + return new EntityState(s.typeKey(), s.x(), s.y(), s.z(), s.bodyYaw(), s.baby(), s.width(), s.height(), + s.player(), s.skinUrl(), s.slim(), s.variant(), s.tint(), s.sizeScale(), s.profession(), s.villagerLevel(), + s.markings(), s.saddle(), s.chest(), s.arms(), s.bodyEquip(), s.equipment(), s.invisible(), s.pitch(), + s.bodyDye(), s.tint2(), s.passenger(), s.rightHand(), s.leftHand(), s.headItem(), s.collar(), true, + s.mouthItem()); + } + + /** + * Grass floor at y=0 over [-48, 48)². Nothing stacked on it: ambient occlusion asks Material.isOccluding() + * for non-air neighbours, which needs a server registry (not available standalone). + */ + static WorldSnapshot farmWorld() { + BlockData air = ObjectTestRender.block(Material.AIR, null); + BlockData grass = ObjectTestRender.block(Material.GRASS_BLOCK, "snowy=false"); + Map chunks = new HashMap<>(); + for (int cx = -3; cx < 3; cx++) { + for (int cz = -3; cz < 3; cz++) { + final int bx0 = cx * 16, bz0 = cz * 16; + ChunkSnapshot cs = (ChunkSnapshot) Proxy.newProxyInstance(EntityBenchmark.class.getClassLoader(), + new Class[]{ChunkSnapshot.class}, (p, m, a) -> { + switch (m.getName()) { + case "getBlockData": { + int y = (int) a[1]; + return y == 0 ? grass : air; + } + case "getBiome": return null; + case "equals": return p == a[0]; + case "hashCode": return System.identityHashCode(p); + case "toString": return "chunk"; + } + Class rt = m.getReturnType(); + if (rt == boolean.class) return false; + if (rt.isPrimitive()) return 0; + return null; + }); + chunks.put(WorldSnapshot.chunkKey(cx, cz), cs); + } + } + return new WorldSnapshot(chunks, 0, 8, air); + } +} diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index d7e0f70..a2e594b 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -136,6 +136,38 @@ public class PPTest extends JavaPlugin { scenes.put("frames_side", new Scene("frames_side", this::frames, -1.5, 3.2, -1.0, 4.5, 2.6, 3.6)); scenes.put("frames_dirs_ne", new Scene("frames_dirs_ne", this::framesDirs, 16, 7.5, -4, 8, 4, 3.5)); scenes.put("frames_dirs_sw", new Scene("frames_dirs_sw", this::framesDirs, 0, 1.2, 10, 8, 4, 3.5)); + // Performance (entity BVH): a 15x18 animal farm (270 mobs, randomizeData=false, fixed body yaw) and 300 + // item displays from a seeded RNG -> deterministic, pixel-diffable; the render time is logged. + scenes.put("perf_farm", new Scene("perf_farm", this::perfFarm, -6, 9, -13, 4, 0, 0)); + scenes.put("perf_farm_close", new Scene("perf_farm_close", this::perfFarm, 0.5, 3, -11.5, 5, 0.5, -4)); + scenes.put("perf_items", new Scene("perf_items", this::perfItems, 0.5, 5, -10, 5, 0, -2)); + } + + private void perfFarm(Location o) { + Random r = new Random(42); + Class[] types = {Cow.class, Sheep.class, Chicken.class, Pig.class}; + for (int i = 0; i < 15; i++) + for (int j = 0; j < 18; j++) { + @SuppressWarnings("unchecked") + Class type = (Class) types[r.nextInt(types.length)]; + LivingEntity e = rfSpawn(o, -5 + i * 1.2, 0, -10 + j * 1.2, 0, type); + if (r.nextInt(5) == 0 && e instanceof Ageable a) a.setBaby(); + e.setBodyYaw(r.nextInt(8) * 45); + } + } + + private void perfItems(Location o) { + Random r = new Random(7); + Material[] mats = {Material.WHEAT, Material.CARROT, Material.EGG, Material.BONE, Material.ROTTEN_FLESH, + Material.STRING, Material.GUNPOWDER, Material.IRON_INGOT, Material.COBBLESTONE, Material.OAK_LOG, + Material.PUMPKIN}; + for (int i = 0; i < 300; i++) { + ItemDisplay d = spawn(o, -4 + r.nextDouble() * 16, 0.15 + r.nextDouble() * 0.2, -10 + r.nextDouble() * 16, + r.nextInt(360), ItemDisplay.class); + d.setItemStack(it(mats[r.nextInt(mats.length)])); + d.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f((float) (Math.PI / 2), 1, 0, 0), + new Vector3f(0.4f), new AxisAngle4f())); + } } @Override @@ -223,7 +255,9 @@ public class PPTest extends JavaPlugin { + " objects, " + job.blockEntities().size() + " block entities"); Bukkit.getScheduler().runTaskAsynchronously(this, () -> { try { + long t0 = System.nanoTime(); BufferedImage img = r.execute(job); + getLogger().info(String.format("TIME %s %.1f ms", sc.name, (System.nanoTime() - t0) / 1e6)); File f = new File(getDataFolder(), sc.name + ".png"); f.getParentFile().mkdirs(); ImageIO.write(img, "png", f);