entity bvh with exact old tie order and allocation-free entity/element intersectors, entity benchmark harness and perf test scenes

This commit is contained in:
MineTec committed 2026-11-04 16:42:08 +01:00
1 parent 2d56a1910c
commit b5baffebbd
6 files changed
+910 -161

No files matched your search

@@ -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). */
@@ -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) {
@@ -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<RenderedEntity> 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<RenderedEntity> 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> TRAVERSAL = ThreadLocal.withInitial(Traversal::new);
public EntityScene(
List<EntityState> 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);
}
}
@@ -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<Candidate>.sort, so ties resolve identically.
final Comparator<Integer> 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> 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<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i);
Candidate c;
List<Element> 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);
}
}
+195
View File
@@ -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<EntityState> mobs, List<ObjectState> 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<String> 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<Vector> 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<Scene> scenes() {
List<Scene> out = new ArrayList<>();
// Mob farm: 400 animals in a 20x20 pen (1.4 block spacing), seen from an elevated corner.
List<EntityState> 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<ObjectState> 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<EntityState> 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<ObjectState> 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<Long, ChunkSnapshot> 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);
}
}
+34
View File
@@ -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<? extends LivingEntity> type = (Class<? extends LivingEntity>) 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);