Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4e5e1ba
perf(light): pack the sections of a chunk state in one pass
theEvilReaper Oct 22, 2026
f80efff
perf(light): test the level before the occlusion when a chunk state s…
theEvilReaper Oct 22, 2026
f442dc6
perf(light): skip the packed table of a uniform section
theEvilReaper Oct 22, 2026
e650884
perf(light): hand a chunk state the flat levels of the propagator
theEvilReaper Oct 22, 2026
bba02aa
perf(light): reuse the propagators of an area pass
theEvilReaper Oct 22, 2026
d073ae7
test(benchmarks): measure the area pass over generated terrain
theEvilReaper Oct 22, 2026
08ada70
perf(light): hand the sky heightmap of the propagator to the chunk state
theEvilReaper Oct 22, 2026
106f532
perf(light): resolve the border face once instead of per level
theEvilReaper Oct 22, 2026
8220539
perf(light): inject a border straight from the neighbouring state
theEvilReaper Oct 22, 2026
031c35d
perf(light): inject only the borders of neighbours which changed
theEvilReaper Oct 22, 2026
1ada9fd
perf(light): keep no emission table for a section without a light source
theEvilReaper Oct 22, 2026
593fff7
test(benchmarks): build the opacity table of a section with light sou…
theEvilReaper Oct 22, 2026
30046d1
perf(light): skip unchanged neighbours when a chunk is lit with its n…
theEvilReaper Oct 22, 2026
160146f
test(benchmarks): measure what the opacity tables of an area retain
theEvilReaper Oct 22, 2026
1bb98fb
perf(light): reuse the propagators of a service across calls
theEvilReaper Oct 22, 2026
f8e0e3a
perf(light): create the retraction queue of a chunk state on first use
theEvilReaper Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/benchmark-catalogue.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ would land inside the `codec` stage and hide the very thing the benchmark isolat
| Benchmark | Kind | Parameters | What it answers |
| --- | --- | --- | --- |
| [`LightNibblesBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/LightNibblesBenchmark.java) `.getUniform` `.getAllocated` `.setUniformUnchanged` `.setAllocating` `.ofArray` | library | – | What the uniform shortcut is worth. A section whose blocks all carry the same level keeps no array and answers from a field; every other section shifts a nibble out of 2048 bytes. Most sections of a world are completely dark or completely sky-lit, so the shortcut is the common path, not the exception. |
| [`SectionOpacityBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java) `.of` | library | `distinctStates` 1, 8, 64, 200 × `resolveCost` 0, 50 | What "resolve each distinct state once" is worth. `distinctStates` sets how often the cache misses; `resolveCost` sets what a miss costs. At `resolveCost = 0` you are measuring the hash map and the two array writes per block, so the table looks like pure overhead. At `resolveCost = 50` you are measuring what it saves: seven resolutions per distinct state instead of seven per block. What 50 tokens corresponds to on a real registry is not established. |
| [`SectionOpacityBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/SectionOpacityBenchmark.java) `.of` | library | `distinctStates` 1, 8, 64, 200 × `resolveCost` 0, 50 × `lightSources` 0, 4 | What "resolve each distinct state once" is worth. `distinctStates` sets how often the cache misses; `resolveCost` sets what a miss costs. At `resolveCost = 0` you are measuring the hash map and the two array writes per block, so the table looks like pure overhead. At `resolveCost = 50` you are measuring what it saves: seven resolutions per distinct state instead of seven per block. What 50 tokens corresponds to on a real registry is not established. `lightSources` decides whether the section keeps an emission table; the sources replace blocks, so at 4 a section has one distinct state more and `distinctStates = 1` is no longer uniform. Figures published before the parameter existed are the `lightSources = 0` rows. |
| [`LightPropagatorBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/LightPropagatorBenchmark.java) `.propagate` | library | `lightSources` 0, 1, 8, 64 × `occlusionPercent` 0, 25 | How the single-section search scales with the amount of queued positions. `lightSources = 0` is answered without a search at all, which is the case for the overwhelming majority of the sections of a world. `occlusionPercent = 25` is there because solid blocks stop the search early — measuring only an open section reports the worst case and calls it normal. |
| [`ChunkLightPropagatorBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/ChunkLightPropagatorBenchmark.java) `.propagate` `.propagateSky` | library | `sectionCount` 4, 16, 24 × `lightSourcesPerSection` 1, 8 | The same search across section borders, over a flat map (4), a shallow world (16) and a full-height overworld (24). Both searches are measured because the engine runs both per chunk and they behave very differently: block light is bounded by the amount of emitting blocks, sky light seeds nearly every block of an open column. |
| [`AreaVsPerChunkBenchmark`](https://github.com/OneLiteFeatherNET/Falco/blob/main/falco-benchmarks/src/jmh/java/net/onelitefeather/falco/benchmark/light/AreaVsPerChunkBenchmark.java) `.area` `.perChunk` | decision | `chunkCount` 1, 4, 9, 16 | Whether area forming earns its complexity: an area of *n* chunks against *n* separate `calculateWithNeighbours` calls. Written as a decision rather than a report — had it not held, the simpler per-chunk design was the better one and area forming was to be dropped rather than tuned. The `chunkCount = 1` row is the control: a lone chunk has no loaded neighbours, so neither side has a ring to read and the two must come out level. |
Expand Down
1 change: 1 addition & 0 deletions falco-benchmarks/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies {
testImplementation(sourceSets["jmh"].output)
testImplementation(project(":falco-anvil"))
testImplementation(project(":falco-instance"))
testImplementation(project(":falco-light"))
testImplementation(libs.adventure.nbt)
testImplementation(libs.annotations)
testImplementation(libs.jmh.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
* <li>A resolve cost above zero measures what the cache saves. Seven resolutions happen per
* distinct state, one per face plus the emission, instead of seven per block.</li>
* </ul>
* <p>
* A third parameter, the amount of light sources, decides whether the section keeps an emission
* table at all. It is there because a change which only made sections without a light source
* cheaper once made the whole engine slower on a world full of them, and this benchmark could not
* tell.
* </p>
*
* @author TheMeinerLP
* @version 1.0.0
Expand All @@ -68,6 +74,19 @@ public class SectionOpacityBenchmark {
@Param({"0", "50"})
public int resolveCost;

/**
* The amount of light emitting blocks the section holds.
* <p>
* Zero is the section most of a world consists of, and a table keeps no emission for it. Four
* is a section below the surface of {@link TerrainAreaBenchmark}, which has to keep one. Without
* this a change to the emission path is invisible here and only shows up on the terrain. The
* sources replace blocks of the section, so they add one distinct state and turn a uniform
* section into a mixed one.
* </p>
*/
@Param({"0", "4"})
public int lightSources;

private int[] stateIds;
private BlockLightSource source;

Expand All @@ -82,13 +101,15 @@ public SectionOpacityBenchmark() {
* <p>
* The states start above {@link FakeBlockLightSource#FILLER_BASE} so every one of them behaves
* like air. The benchmark measures how often a state is resolved, not what the answer is, and a
* mixture of solid and transparent blocks would only add noise to that.
* mixture of solid and transparent blocks would only add noise to that. The light sources are
* the one exception, because whether a section emits decides which tables it keeps.
* </p>
*/
@Setup(Level.Trial)
public void setUp() {
this.stateIds = SectionStates.distinct(
BenchmarkConstants.BLOCK_ENTRIES, this.distinctStates, FakeBlockLightSource.FILLER_BASE
this.stateIds = SectionStates.withLightSources(
SectionStates.distinct(BenchmarkConstants.BLOCK_ENTRIES, this.distinctStates, FakeBlockLightSource.FILLER_BASE),
this.lightSources
);
this.source = new FakeBlockLightSource(this.resolveCost);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
package net.onelitefeather.falco.benchmark.light;

import net.minestom.server.MinecraftServer;
import net.minestom.server.instance.Chunk;
import net.minestom.server.instance.Instance;
import net.minestom.server.instance.block.Block;
import net.onelitefeather.falco.light.ChunkArea;
import net.onelitefeather.falco.light.ChunkLightArea;
import net.onelitefeather.falco.light.ChunkLightService;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
* The {@link TerrainAreaBenchmark} class measures an area pass over a world that looks like one.
* <p>
* <b>Why it exists next to {@link AreaPassStageBenchmark}.</b> Every other light benchmark fills a
* single slab of sixteen blocks with random stone and leaves the rest of the column as air. That
* makes one section per chunk interesting and the other twenty-three uniform, and a uniform section
* is the case every stage of the engine answers without a table. The light of such a world settles
* in a few blocks, so the benchmarks measure the fixed cost of a pass far more than the search.
* </p>
* <p>
* The world here is the opposite on purpose. Hills rise from solid ground, caves run through the
* stone below them and open into the surface as overhangs, and glowstone sits in the caves and on
* the ground. Many sections of a column are mixed, light travels across chunk borders through the
* caves, and the sky light has to find its way under every overhang. Both kinds of light are
* measured, because a tick pays for both.
* </p>
* <p>
* The terrain is computed from seeded value noise in world coordinates rather than per chunk, so
* caves and hills continue across borders the way they do in a generated world. The area and its
* ring are filled alike for the reason {@link AreaPassStageBenchmark} gives: the ring is read on
* every pass, and an empty ring would make reading it look free.
* </p>
*
* @author theEvilReaper
* @version 1.0.0
* @since 1.0.0
*/
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Fork(value = 1, jvmArgsAppend = {"-Xms2g", "-Xmx2g"})
@Warmup(iterations = 3, time = 2)
@Measurement(iterations = 5, time = 2)
public class TerrainAreaBenchmark {

/**
* The seed of the terrain, so every run measures the same world.
*/
private static final long SEED = 20261022L;

/**
* The height the hills oscillate around.
*/
private static final int SEA_LEVEL = 64;

/**
* How far the surface rises above or sinks below {@link #SEA_LEVEL}.
*/
private static final int HILL_AMPLITUDE = 24;

/**
* The horizontal size of one hill, in blocks.
*/
private static final int HILL_SCALE = 32;

/**
* The size of one cell of the cave noise, in blocks.
*/
private static final int CAVE_SCALE = 8;

/**
* The noise value above which a position of the ground is a cave. Lower values carve more.
*/
private static final double CAVE_THRESHOLD = 0.62;

/**
* The attempts made per chunk to put a light source into a cave.
*/
private static final int CAVE_SOURCE_ATTEMPTS = 64;

/**
* The light sources placed on the surface of every chunk.
*/
private static final int SURFACE_SOURCES = 4;

/**
* The amount of connected chunks the area holds.
*/
@Param({"1", "16", "64"})
private int chunkCount;

/**
* Whether the sky light is computed instead of the block light.
*/
@Param({"false", "true"})
private boolean sky;

private Instance instance;
private ChunkLightArea area;
private List<ChunkArea> areaPositions;

/**
* Creates a new benchmark instance.
*/
public TerrainAreaBenchmark() {
}

/**
* Builds the square, loads the ring around it and generates the terrain into both.
*/
@Setup(Level.Trial)
public void setUp() {
if (MinecraftServer.process() == null) {
MinecraftServer.init();
}

this.area = new ChunkLightArea(new ChunkLightService());
this.instance = MinecraftServer.getInstanceManager().createInstanceContainer();
this.areaPositions = new ArrayList<>(this.chunkCount);

int edge = (int) Math.round(Math.sqrt(this.chunkCount));

for (int index = 0; index < this.chunkCount; index++) {
this.areaPositions.add(new ChunkArea(index % edge, index / edge));
}

Set<ChunkArea> pass = new LinkedHashSet<>(this.areaPositions);

for (ChunkArea position : this.areaPositions) {
for (int offsetX = -1; offsetX <= 1; offsetX++) {
for (int offsetZ = -1; offsetZ <= 1; offsetZ++) {
pass.add(new ChunkArea(position.x() + offsetX, position.z() + offsetZ));
}
}
}

for (ChunkArea position : pass) {
generate(this.instance.loadChunk(position.x(), position.z()).join());
}
}

/**
* Generates hills, caves and light sources into the given chunk.
*
* @param chunk the chunk to fill
*/
static void generate(Chunk chunk) {
int baseX = chunk.getChunkX() * 16;
int baseZ = chunk.getChunkZ() * 16;
int minY = chunk.getMinSection() * 16;
// Seeded per chunk, so a chunk receives the same sources whatever order the ring is loaded in.
Random random = new Random(SEED ^ (31L * chunk.getChunkX() + chunk.getChunkZ()) * 0x9E3779B97F4A7C15L);

chunk.lockWriteLock();
try {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int worldX = baseX + x;
int worldZ = baseZ + z;
int surface = surfaceAt(worldX, worldZ);

for (int y = minY; y <= surface; y++) {
if (!isCave(worldX, y, worldZ)) {
chunk.setBlock(worldX, y, worldZ, Block.STONE);
}
}
}
}

for (int attempt = 0; attempt < CAVE_SOURCE_ATTEMPTS; attempt++) {
int worldX = baseX + random.nextInt(16);
int worldZ = baseZ + random.nextInt(16);
int y = minY + random.nextInt(surfaceAt(worldX, worldZ) - minY);

if (isCave(worldX, y, worldZ)) {
chunk.setBlock(worldX, y, worldZ, Block.GLOWSTONE);
}
}

for (int placed = 0; placed < SURFACE_SOURCES; placed++) {
int worldX = baseX + random.nextInt(16);
int worldZ = baseZ + random.nextInt(16);
chunk.setBlock(worldX, surfaceAt(worldX, worldZ) + 1, worldZ, Block.GLOWSTONE);
}
} finally {
chunk.unlockWriteLock();
}
}

/**
* Returns the height of the ground at the given column.
*
* @param worldX the x coordinate of the column
* @param worldZ the z coordinate of the column
* @return the y coordinate of the topmost ground block
*/
private static int surfaceAt(int worldX, int worldZ) {
double broad = noise(worldX / (double) HILL_SCALE, 0, worldZ / (double) HILL_SCALE, 1);
double fine = noise(worldX / (HILL_SCALE / 4.0), 0, worldZ / (HILL_SCALE / 4.0), 2);
return SEA_LEVEL + (int) Math.round(((broad * 0.8 + fine * 0.2) * 2 - 1) * HILL_AMPLITUDE);
}

/**
* Checks whether the given position of the ground is carved out by a cave.
*
* @param worldX the x coordinate
* @param y the y coordinate
* @param worldZ the z coordinate
* @return true if the position is air, otherwise false
*/
private static boolean isCave(int worldX, int y, int worldZ) {
return noise(worldX / (double) CAVE_SCALE, y / (double) CAVE_SCALE, worldZ / (double) CAVE_SCALE, 3)
> CAVE_THRESHOLD;
}

/**
* Samples smoothly interpolated value noise.
*
* @param x the x coordinate in noise cells
* @param y the y coordinate in noise cells
* @param z the z coordinate in noise cells
* @param channel separates independent noise fields that share coordinates
* @return a value within [0, 1]
*/
private static double noise(double x, double y, double z, int channel) {
int cellX = (int) Math.floor(x);
int cellY = (int) Math.floor(y);
int cellZ = (int) Math.floor(z);
double fractionX = smooth(x - cellX);
double fractionY = smooth(y - cellY);
double fractionZ = smooth(z - cellZ);

double bottom = lerp(fractionZ,
lerp(fractionX, corner(cellX, cellY, cellZ, channel), corner(cellX + 1, cellY, cellZ, channel)),
lerp(fractionX, corner(cellX, cellY, cellZ + 1, channel), corner(cellX + 1, cellY, cellZ + 1, channel)));
double top = lerp(fractionZ,
lerp(fractionX, corner(cellX, cellY + 1, cellZ, channel), corner(cellX + 1, cellY + 1, cellZ, channel)),
lerp(fractionX, corner(cellX, cellY + 1, cellZ + 1, channel), corner(cellX + 1, cellY + 1, cellZ + 1, channel)));
return lerp(fractionY, bottom, top);
}

/**
* Returns the random value of one corner of the noise lattice.
*
* @param x the x coordinate of the corner
* @param y the y coordinate of the corner
* @param z the z coordinate of the corner
* @param channel the noise field the corner belongs to
* @return a value within [0, 1)
*/
private static double corner(int x, int y, int z, int channel) {
long hash = SEED + channel * 0x632BE59BD9B4E019L;
hash ^= x * 0x9E3779B97F4A7C15L;
hash ^= y * 0xC2B2AE3D27D4EB4FL;
hash ^= z * 0x165667B19E3779F9L;
hash = (hash ^ (hash >>> 31)) * 0xBF58476D1CE4E5B9L;
hash = (hash ^ (hash >>> 29)) * 0x94D049BB133111EBL;
hash ^= hash >>> 32;
return (hash >>> 11) * 0x1.0p-53;
}

/**
* Eases the given fraction so the noise has no visible edges at the cell borders.
*
* @param fraction a value within [0, 1]
* @return the eased value
*/
private static double smooth(double fraction) {
return fraction * fraction * (3 - 2 * fraction);
}

/**
* Interpolates linearly between two values.
*
* @param fraction the share of the second value
* @param from the first value
* @param to the second value
* @return the interpolated value
*/
private static double lerp(double fraction, double from, double to) {
return from + fraction * (to - from);
}

/**
* Computes the light of the whole area, the ring included, and writes the area back.
*
* @return the chunks which were written
*/
@Benchmark
public List<ChunkArea> wholePass() {
return this.area.compute(this.instance, this.areaPositions, this.sky);
}
}
Loading
Loading