Cubic chunks + no Y limit (foundation for LOD)
Chunks are now isotropic 16×16×16 cubes stacked in all three axes,
keyed by IVec3(cx, cy, cz). The world has no Y ceiling and no Y
floor — set_block lazily creates a chunk if one doesn't exist, so
building 1000 blocks straight up just creates 60-ish empty chunks
above the surface on demand.
world.rs:
- CHUNK_HEIGHT removed. Single CHUNK_SIZE (=16) for all three axes.
- Chunk allocates 16³ = 4096 blocks.
- Chunk::get/set bound-check 0..CHUNK_SIZE on every axis.
- block_to_chunk returns full 3D (cx, cy, cz) + (lx, ly, lz).
- get_block has no Y bound — outside any chunk = Air (sky).
- set_block: chunks.entry(c).or_insert_with(...) creates on demand.
- HeightMap is keyed by IVec2(cx, cz) column. from_world_column scans
every existing cubic chunk in that column top-down for the topmost
solid block. Heightmap invalidation in set_block drops the column,
not just the single chunk.
- column_top_y, World::heightmap signature both take (cx, cz) directly.
- World::new pre-generates chunks at cy in -1..=2 (4 vertical levels
of pre-gen, ~1.1k chunks total, ~4.6 MB) covering the natural
surface band. Above/below grows lazily.
- generate_chunk takes the full 3D chunk coord; computes oy, fills
blocks for this slice of the column. Trees that straddle a chunk
boundary deterministically fill in their portion (each chunk uses
the same hash, writes only its slice).
- natural_surface_y returns the topmost-solid Y (preserved old
semantic so all collision / spawn tests still hold).
- 2 new tests pin the lazy-chunk-creation invariant (above and
below pre-gen ranges).
mesh.rs:
- dims is [CHUNK_SIZE; 3]; iteration loops are cubic.
- base_y added so neighbor lookups + corner positions account for
the chunk's vertical offset.
- warm_heightmaps_around takes (cx, cz) column coords now.
sim/spawn.rs:
- find_safe_spawn no longer caps at CHUNK_HEIGHT-2. Safety bound
is surface_y + 200 (pathological tower depth before giving up).
sim/lighting.rs:
- SUN_RAY_TOP_Y bumped from 128 to 4096 (was matching the old
fixed world height). Ray DDA still terminates at the cap.
sim/visibility.rs:
- Chunk AABB is CHUNK_SIZE on all three axes; the chunk's cy now
contributes to the AABB min Y (was hardcoded 0).
render/mod.rs:
- Heightmap warming + comment updates for column-keyed semantics.
Tests: 65/65 pass (was 63, +2 new). Native + wasm release clean.
Smoke-tested live: 88 fps, alive, spawn at y=8. All four UI
scenarios pass.
This is the foundation for the LOD pyramid you directed earlier —
isotropic chunks downsample cleanly (a LOD-N chunk represents a
(2^N)³ region with 16³ entries), and unbounded Y lets the LOD
tree extend in any direction without special cases.
This commit is contained in:
parent
c8def4ae45
commit
b2e50b62b5
6 changed files with 228 additions and 124 deletions
48
src/mesh.rs
48
src/mesh.rs
|
|
@ -1,15 +1,17 @@
|
||||||
use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE};
|
use crate::world::{Block, Chunk, Face, World, CHUNK_SIZE};
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use glam::{IVec3, Vec3};
|
use glam::{IVec3, Vec3};
|
||||||
|
|
||||||
/// Ensure heightmaps for this chunk and its 8 neighbors are cached,
|
/// Ensure heightmaps for this chunk's (cx, cz) column + its 8 neighbor
|
||||||
/// so the per-vertex ambience bake can do O(1) column lookups via
|
/// columns are cached so the per-vertex ambience bake can do O(1)
|
||||||
/// `world.column_top_y(..)`. Run once per chunk at the start of
|
/// `column_top_y` lookups. Idempotent — recomputes only uncached
|
||||||
/// `build_chunk_mesh`. Idempotent — only computes uncached entries.
|
/// columns. With cubic chunks, the heightmap is keyed on the
|
||||||
|
/// horizontal column, not on the chunk's vertical level — one
|
||||||
|
/// heightmap serves every cubic chunk in the column.
|
||||||
pub fn warm_heightmaps_around(world: &mut World, coord: IVec3) {
|
pub fn warm_heightmaps_around(world: &mut World, coord: IVec3) {
|
||||||
for dx in -1..=1 {
|
for dx in -1..=1 {
|
||||||
for dz in -1..=1 {
|
for dz in -1..=1 {
|
||||||
let _ = world.heightmap(IVec3::new(coord.x + dx, 0, coord.z + dz));
|
let _ = world.heightmap(coord.x + dx, coord.z + dz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,9 +110,11 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
||||||
let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
|
let mut vertices: Vec<Vertex> = Vec::with_capacity(2048);
|
||||||
let mut indices: Vec<u32> = Vec::with_capacity(3072);
|
let mut indices: Vec<u32> = Vec::with_capacity(3072);
|
||||||
let base_x = chunk.coord.x * CHUNK_SIZE;
|
let base_x = chunk.coord.x * CHUNK_SIZE;
|
||||||
|
let base_y = chunk.coord.y * CHUNK_SIZE;
|
||||||
let base_z = chunk.coord.z * CHUNK_SIZE;
|
let base_z = chunk.coord.z * CHUNK_SIZE;
|
||||||
|
|
||||||
let dims = [CHUNK_SIZE, CHUNK_HEIGHT, CHUNK_SIZE];
|
// Cubic chunks — every axis is CHUNK_SIZE.
|
||||||
|
let dims = [CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE];
|
||||||
|
|
||||||
for face in Face::ALL {
|
for face in Face::ALL {
|
||||||
let normal = face.normal();
|
let normal = face.normal();
|
||||||
|
|
@ -149,20 +153,28 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
||||||
let nx = p[0] + normal.x;
|
let nx = p[0] + normal.x;
|
||||||
let ny = p[1] + normal.y;
|
let ny = p[1] + normal.y;
|
||||||
let nz = p[2] + normal.z;
|
let nz = p[2] + normal.z;
|
||||||
let neighbor_solid = if ny < 0 || ny >= CHUNK_HEIGHT {
|
// Neighbor lookup: in-chunk if all locals in
|
||||||
false
|
// [0, CHUNK_SIZE); otherwise query the world,
|
||||||
} else if nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE {
|
// which may cross into a vertically adjacent
|
||||||
|
// chunk (the new cubic case).
|
||||||
|
let in_chunk = nx >= 0
|
||||||
|
&& nx < CHUNK_SIZE
|
||||||
|
&& ny >= 0
|
||||||
|
&& ny < CHUNK_SIZE
|
||||||
|
&& nz >= 0
|
||||||
|
&& nz < CHUNK_SIZE;
|
||||||
|
let neighbor_solid = if in_chunk {
|
||||||
chunk.get(nx, ny, nz).solid()
|
chunk.get(nx, ny, nz).solid()
|
||||||
} else {
|
} else {
|
||||||
world
|
world
|
||||||
.get_block(IVec3::new(base_x + nx, ny, base_z + nz))
|
.get_block(IVec3::new(base_x + nx, base_y + ny, base_z + nz))
|
||||||
.solid()
|
.solid()
|
||||||
};
|
};
|
||||||
if neighbor_solid {
|
if neighbor_solid {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// World-space block position.
|
// World-space block position.
|
||||||
let block_world = IVec3::new(base_x + p[0], p[1], base_z + p[2]);
|
let block_world = IVec3::new(base_x + p[0], base_y + p[1], base_z + p[2]);
|
||||||
let face_plane = block_world + normal;
|
let face_plane = block_world + normal;
|
||||||
// 4 corner AO values, ordered (min-u min-v), (max-u min-v),
|
// 4 corner AO values, ordered (min-u min-v), (max-u min-v),
|
||||||
// (max-u max-v), (min-u max-v).
|
// (max-u max-v), (min-u max-v).
|
||||||
|
|
@ -206,7 +218,13 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
||||||
p[axis] = slice as f32;
|
p[axis] = slice as f32;
|
||||||
p[u_axis] = u_val as f32;
|
p[u_axis] = u_val as f32;
|
||||||
p[v_axis] = v_val as f32;
|
p[v_axis] = v_val as f32;
|
||||||
[p[0] + base_x as f32, p[1], p[2] + base_z as f32]
|
// All three axes contribute a chunk-base
|
||||||
|
// offset now that chunks are cubic.
|
||||||
|
[
|
||||||
|
p[0] + base_x as f32,
|
||||||
|
p[1] + base_y as f32,
|
||||||
|
p[2] + base_z as f32,
|
||||||
|
]
|
||||||
};
|
};
|
||||||
let c0 = to_world(u0, v0);
|
let c0 = to_world(u0, v0);
|
||||||
let c1 = to_world(u0 + w, v0);
|
let c1 = to_world(u0 + w, v0);
|
||||||
|
|
@ -415,7 +433,7 @@ pub fn name_hash(s: &str) -> u32 {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::world::{Block, Chunk, World, CHUNK_HEIGHT, CHUNK_SIZE};
|
use crate::world::{Block, Chunk, World, CHUNK_SIZE};
|
||||||
|
|
||||||
/// A world containing exactly one chunk at the origin with all blocks
|
/// A world containing exactly one chunk at the origin with all blocks
|
||||||
/// you put into it via the closure, and nothing else.
|
/// you put into it via the closure, and nothing else.
|
||||||
|
|
@ -570,8 +588,8 @@ mod tests {
|
||||||
// is being rearranged.
|
// is being rearranged.
|
||||||
#[test]
|
#[test]
|
||||||
fn world_constants_are_sane() {
|
fn world_constants_are_sane() {
|
||||||
|
// Cubic chunks: just one CHUNK_SIZE for all three axes.
|
||||||
assert!(CHUNK_SIZE > 0);
|
assert!(CHUNK_SIZE > 0);
|
||||||
assert!(CHUNK_HEIGHT > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- emit_oriented_box ----------
|
// ---------- emit_oriented_box ----------
|
||||||
|
|
|
||||||
|
|
@ -495,10 +495,11 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &mut World) {
|
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &mut World) {
|
||||||
// Heightmaps for this chunk + 8 neighbors are required by the
|
// Heightmaps for this chunk's column + 8 neighbor columns are
|
||||||
// fast ambience bake. Cheap (O(N²·CHUNK_HEIGHT) per chunk,
|
// required by the fast ambience bake. Cheap (one scan across
|
||||||
// cached). Called before build_chunk_mesh, never lazily inside
|
// the column's stacked chunks, cached). Called before
|
||||||
// it, so the build path stays free of `&mut World`.
|
// build_chunk_mesh, never lazily inside it, so the build path
|
||||||
|
// stays free of `&mut World`.
|
||||||
warm_heightmaps_around(world, coord);
|
warm_heightmaps_around(world, coord);
|
||||||
let Some(chunk) = world.chunks.get(&coord) else {
|
let Some(chunk) = world.chunks.get(&coord) else {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@ pub const DAY_PERIOD: f32 = 300.0;
|
||||||
/// Phase offset so a fresh world starts at noon (0.25 cycles past dawn).
|
/// Phase offset so a fresh world starts at noon (0.25 cycles past dawn).
|
||||||
pub const SUN_OFFSET: f32 = 0.25;
|
pub const SUN_OFFSET: f32 = 0.25;
|
||||||
/// Y above which the sun-occlusion ray is considered to have escaped
|
/// Y above which the sun-occlusion ray is considered to have escaped
|
||||||
/// the world (open sky). Must be ≥ `world::CHUNK_HEIGHT`.
|
/// the world (open sky). Since cubic chunks now extend Y indefinitely,
|
||||||
pub const SUN_RAY_TOP_Y: i32 = 128;
|
/// this is just a "we've gone too high to keep looking" cap — large
|
||||||
|
/// enough that any reasonable structure is below it but not unbounded
|
||||||
|
/// so degenerate rays still terminate.
|
||||||
|
pub const SUN_RAY_TOP_Y: i32 = 4096;
|
||||||
/// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop
|
/// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop
|
||||||
/// forever. 512 voxels is well past any reasonable world width.
|
/// forever. 512 voxels is well past any reasonable world width.
|
||||||
const SUN_RAY_MAX_STEPS: u32 = 512;
|
const SUN_RAY_MAX_STEPS: u32 = 512;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Spawn-point selection and fall-damage. Both are pure functions of
|
//! Spawn-point selection and fall-damage. Both are pure functions of
|
||||||
//! world state and a scalar input, so they're easy to pin with
|
//! world state and a scalar input, so they're easy to pin with
|
||||||
//! regression tests.
|
//! regression tests.
|
||||||
use crate::world::{natural_surface_y, World, CHUNK_HEIGHT};
|
use crate::world::{natural_surface_y, World};
|
||||||
use glam::{IVec3, Vec3};
|
use glam::{IVec3, Vec3};
|
||||||
|
|
||||||
/// Returns the player feet position to spawn at. Anchored to the
|
/// Returns the player feet position to spawn at. Anchored to the
|
||||||
|
|
@ -13,7 +13,12 @@ pub fn find_safe_spawn(world: &World) -> Vec3 {
|
||||||
let (x, z) = (0_i32, 0_i32);
|
let (x, z) = (0_i32, 0_i32);
|
||||||
let surface_y = natural_surface_y(x, z);
|
let surface_y = natural_surface_y(x, z);
|
||||||
let mut feet_y = surface_y + 1;
|
let mut feet_y = surface_y + 1;
|
||||||
let max_y = CHUNK_HEIGHT - 2;
|
// No CHUNK_HEIGHT bound anymore — cubic chunks stack indefinitely.
|
||||||
|
// We still cap the scan at +200 above the surface as a safety,
|
||||||
|
// since any tower that tall + a clear cap above would be
|
||||||
|
// pathological and any actual spawn would have found a slot well
|
||||||
|
// before there.
|
||||||
|
let max_y = surface_y + 200;
|
||||||
while feet_y < max_y {
|
while feet_y < max_y {
|
||||||
let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid()
|
let body_blocked = world.get_block(IVec3::new(x, feet_y, z)).solid()
|
||||||
|| world.get_block(IVec3::new(x, feet_y + 1, z)).solid();
|
|| world.get_block(IVec3::new(x, feet_y + 1, z)).solid();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Frustum + radial culling for chunk visibility. Pure function on
|
//! Frustum + radial culling for chunk visibility. Pure function on
|
||||||
//! `(World, Camera, render_dist) -> Vec<ChunkCoord>`.
|
//! `(World, Camera, render_dist) -> Vec<ChunkCoord>`.
|
||||||
use crate::camera::Camera;
|
use crate::camera::Camera;
|
||||||
use crate::world::{World, CHUNK_HEIGHT, CHUNK_SIZE};
|
use crate::world::{World, CHUNK_SIZE};
|
||||||
use glam::{IVec3, Vec3};
|
use glam::{IVec3, Vec3};
|
||||||
|
|
||||||
/// Returns the chunk coordinates whose AABB intersects the camera's
|
/// Returns the chunk coordinates whose AABB intersects the camera's
|
||||||
|
|
@ -41,12 +41,15 @@ fn frustum_planes(camera: &Camera) -> [[f32; 4]; 6] {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chunk_in_frustum(coord: IVec3, planes: &[[f32; 4]; 6]) -> bool {
|
fn chunk_in_frustum(coord: IVec3, planes: &[[f32; 4]; 6]) -> bool {
|
||||||
|
// Cubic chunks — all three axes are CHUNK_SIZE wide and the
|
||||||
|
// chunk's Y coordinate now matters (was always 0 in the old
|
||||||
|
// column-chunk model).
|
||||||
let min = Vec3::new(
|
let min = Vec3::new(
|
||||||
(coord.x * CHUNK_SIZE) as f32,
|
(coord.x * CHUNK_SIZE) as f32,
|
||||||
0.0,
|
(coord.y * CHUNK_SIZE) as f32,
|
||||||
(coord.z * CHUNK_SIZE) as f32,
|
(coord.z * CHUNK_SIZE) as f32,
|
||||||
);
|
);
|
||||||
let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32);
|
let max = min + Vec3::splat(CHUNK_SIZE as f32);
|
||||||
for p in planes {
|
for p in planes {
|
||||||
// p-vertex (the AABB corner furthest along plane normal).
|
// p-vertex (the AABB corner furthest along plane normal).
|
||||||
let px = if p[0] > 0.0 { max.x } else { min.x };
|
let px = if p[0] > 0.0 { max.x } else { min.x };
|
||||||
|
|
|
||||||
270
src/world.rs
270
src/world.rs
|
|
@ -1,10 +1,18 @@
|
||||||
use glam::{IVec3, Vec3};
|
use glam::{IVec2, IVec3, Vec3};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub const CHUNK_SIZE: i32 = 16;
|
pub const CHUNK_SIZE: i32 = 16;
|
||||||
pub const CHUNK_HEIGHT: i32 = 64;
|
|
||||||
pub const WORLD_RADIUS: i32 = 8;
|
pub const WORLD_RADIUS: i32 = 8;
|
||||||
|
|
||||||
|
/// Vertical range of chunks pre-generated around the surface, in chunk
|
||||||
|
/// units (so 4 = 64 blocks of vertical pre-gen, centered on the terrain).
|
||||||
|
/// The world has no hard Y limit — players can dig below or build above
|
||||||
|
/// this range, and `set_block` lazily creates chunks on demand. The
|
||||||
|
/// pre-gen range exists so the world has actual terrain visible on
|
||||||
|
/// init without waiting for streaming.
|
||||||
|
pub const PRE_GEN_BOTTOM_CHUNK_Y: i32 = -1;
|
||||||
|
pub const PRE_GEN_TOP_CHUNK_Y: i32 = 2;
|
||||||
|
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Block {
|
pub enum Block {
|
||||||
|
|
@ -115,6 +123,11 @@ impl Face {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cubic 16×16×16 voxel chunk. The chunk grid extends in all three
|
||||||
|
/// axes — `coord.y` is the vertical chunk index, so the world has no
|
||||||
|
/// fixed top or bottom. Chunks are created lazily on edit (see
|
||||||
|
/// `World::set_block`) so building straight up doesn't need any
|
||||||
|
/// pre-allocation.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Chunk {
|
pub struct Chunk {
|
||||||
pub blocks: Vec<Block>,
|
pub blocks: Vec<Block>,
|
||||||
|
|
@ -125,26 +138,29 @@ pub struct Chunk {
|
||||||
impl Chunk {
|
impl Chunk {
|
||||||
pub fn new(coord: IVec3) -> Self {
|
pub fn new(coord: IVec3) -> Self {
|
||||||
Self {
|
Self {
|
||||||
blocks: vec![Block::Air; (CHUNK_SIZE * CHUNK_HEIGHT * CHUNK_SIZE) as usize],
|
blocks: vec![Block::Air; (CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) as usize],
|
||||||
coord,
|
coord,
|
||||||
dirty: true,
|
dirty: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flatten (x, y, z) within this chunk into the linear array index.
|
||||||
|
/// Layout is z-major then y then x; chosen so iterating x last is
|
||||||
|
/// cache-friendly for the greedy-mesh axis loops.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn index(x: i32, y: i32, z: i32) -> usize {
|
pub fn index(x: i32, y: i32, z: i32) -> usize {
|
||||||
((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize
|
((y * CHUNK_SIZE + z) * CHUNK_SIZE + x) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, x: i32, y: i32, z: i32) -> Block {
|
pub fn get(&self, x: i32, y: i32, z: i32) -> Block {
|
||||||
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE {
|
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE {
|
||||||
return Block::Air;
|
return Block::Air;
|
||||||
}
|
}
|
||||||
self.blocks[Self::index(x, y, z)]
|
self.blocks[Self::index(x, y, z)]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(&mut self, x: i32, y: i32, z: i32, b: Block) {
|
pub fn set(&mut self, x: i32, y: i32, z: i32, b: Block) {
|
||||||
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_HEIGHT || z < 0 || z >= CHUNK_SIZE {
|
if x < 0 || x >= CHUNK_SIZE || y < 0 || y >= CHUNK_SIZE || z < 0 || z >= CHUNK_SIZE {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.blocks[Self::index(x, y, z)] = b;
|
self.blocks[Self::index(x, y, z)] = b;
|
||||||
|
|
@ -152,13 +168,14 @@ impl Chunk {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-chunk topmost-solid-Y map: `heights[z * CHUNK_SIZE + x]` =
|
/// Per-column top-of-solid map. Keyed by horizontal chunk coord
|
||||||
/// highest world-Y at which `(chunk_x*16+x, y, chunk_z*16+z)` is a
|
/// `(cx, cz)` and indexed by chunk-local `(lx, lz)` in `0..CHUNK_SIZE`.
|
||||||
/// solid voxel. `i32::MIN` if no solid in the column.
|
/// Each entry is the highest world-Y at which `(cx*16+lx, *, cz*16+lz)`
|
||||||
|
/// contains a solid voxel, across all vertical chunks at that column.
|
||||||
|
/// `i32::MIN` if the column has no solid block anywhere.
|
||||||
///
|
///
|
||||||
/// Computed in O(N²·CHUNK_HEIGHT) once per chunk and cached so the
|
/// Used by `sim::lighting::compute_ambience_fast` to do O(1) lookups
|
||||||
/// sky_visibility bake can do O(1) array lookups instead of casting
|
/// instead of casting hemisphere rays. Recomputed on edit per column.
|
||||||
/// hemisphere rays. Recomputed on edit via `Chunk::dirty_heightmap`.
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HeightMap {
|
pub struct HeightMap {
|
||||||
pub heights: Vec<i32>,
|
pub heights: Vec<i32>,
|
||||||
|
|
@ -171,8 +188,6 @@ impl HeightMap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// World-space `(x, z)` highest solid Y in this chunk. Caller must
|
|
||||||
/// convert to chunk-local coords (0..CHUNK_SIZE).
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn get_local(&self, lx: i32, lz: i32) -> i32 {
|
pub fn get_local(&self, lx: i32, lz: i32) -> i32 {
|
||||||
if lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE {
|
if lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE {
|
||||||
|
|
@ -181,19 +196,36 @@ impl HeightMap {
|
||||||
self.heights[(lz * CHUNK_SIZE + lx) as usize]
|
self.heights[(lz * CHUNK_SIZE + lx) as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_chunk(chunk: &Chunk) -> Self {
|
/// Build the heightmap by scanning every chunk at `(cx, *, cz)`
|
||||||
|
/// top-down for the topmost solid block. Cubic chunks mean we
|
||||||
|
/// can't assume a single Y range; we just look at whatever
|
||||||
|
/// chunks exist in that column.
|
||||||
|
pub fn from_world_column(world: &World, cx: i32, cz: i32) -> Self {
|
||||||
let mut h = Self::new();
|
let mut h = Self::new();
|
||||||
for z in 0..CHUNK_SIZE {
|
// Collect cy values that exist in this column, then process
|
||||||
for x in 0..CHUNK_SIZE {
|
// top-down so we early-exit per (lx, lz) on first solid hit.
|
||||||
|
let mut cy_list: Vec<i32> = world
|
||||||
|
.chunks
|
||||||
|
.keys()
|
||||||
|
.filter(|c| c.x == cx && c.z == cz)
|
||||||
|
.map(|c| c.y)
|
||||||
|
.collect();
|
||||||
|
cy_list.sort_unstable_by(|a, b| b.cmp(a)); // descending
|
||||||
|
|
||||||
|
for lx in 0..CHUNK_SIZE {
|
||||||
|
for lz in 0..CHUNK_SIZE {
|
||||||
let mut top = i32::MIN;
|
let mut top = i32::MIN;
|
||||||
// Scan top-down so we early-exit on first solid.
|
'cy: for cy in &cy_list {
|
||||||
for y in (0..CHUNK_HEIGHT).rev() {
|
let coord = IVec3::new(cx, *cy, cz);
|
||||||
if chunk.blocks[Chunk::index(x, y, z)].solid() {
|
let Some(chunk) = world.chunks.get(&coord) else { continue };
|
||||||
top = y;
|
for ly in (0..CHUNK_SIZE).rev() {
|
||||||
break;
|
if chunk.blocks[Chunk::index(lx, ly, lz)].solid() {
|
||||||
|
top = cy * CHUNK_SIZE + ly;
|
||||||
|
break 'cy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.heights[(z * CHUNK_SIZE + x) as usize] = top;
|
h.heights[(lz * CHUNK_SIZE + lx) as usize] = top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h
|
h
|
||||||
|
|
@ -208,12 +240,8 @@ impl Default for HeightMap {
|
||||||
|
|
||||||
pub struct World {
|
pub struct World {
|
||||||
pub chunks: HashMap<IVec3, Chunk>,
|
pub chunks: HashMap<IVec3, Chunk>,
|
||||||
/// Lazily-built per-chunk heightmap cache. `World::heightmap()`
|
/// Per-(cx, cz) column heightmap cache. Invalidated on `set_block`.
|
||||||
/// computes-and-caches; mesh rebuilds invalidate via `set_block`.
|
pub heightmaps: HashMap<IVec2, HeightMap>,
|
||||||
/// Kept on `World` rather than `Chunk` so it can be recomputed in
|
|
||||||
/// a single immutable-borrow pass without aliasing the chunks
|
|
||||||
/// HashMap.
|
|
||||||
pub heightmaps: HashMap<IVec3, HeightMap>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl World {
|
impl World {
|
||||||
|
|
@ -221,9 +249,11 @@ impl World {
|
||||||
let mut chunks = HashMap::new();
|
let mut chunks = HashMap::new();
|
||||||
for cx in -WORLD_RADIUS..=WORLD_RADIUS {
|
for cx in -WORLD_RADIUS..=WORLD_RADIUS {
|
||||||
for cz in -WORLD_RADIUS..=WORLD_RADIUS {
|
for cz in -WORLD_RADIUS..=WORLD_RADIUS {
|
||||||
let coord = IVec3::new(cx, 0, cz);
|
for cy in PRE_GEN_BOTTOM_CHUNK_Y..=PRE_GEN_TOP_CHUNK_Y {
|
||||||
let chunk = generate_chunk(coord);
|
let coord = IVec3::new(cx, cy, cz);
|
||||||
chunks.insert(coord, chunk);
|
let chunk = generate_chunk(coord);
|
||||||
|
chunks.insert(coord, chunk);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -232,51 +262,45 @@ impl World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get (or compute + cache) the heightmap for `chunk_coord`. Used
|
/// Lookup or compute the heightmap for column `(cx, cz)`.
|
||||||
/// by the sky-visibility bake to do O(1) column lookups instead
|
pub fn heightmap(&mut self, cx: i32, cz: i32) -> &HeightMap {
|
||||||
/// of casting hemisphere rays.
|
let key = IVec2::new(cx, cz);
|
||||||
pub fn heightmap(&mut self, chunk_coord: IVec3) -> &HeightMap {
|
if !self.heightmaps.contains_key(&key) {
|
||||||
if !self.heightmaps.contains_key(&chunk_coord) {
|
let h = HeightMap::from_world_column(self, cx, cz);
|
||||||
if let Some(chunk) = self.chunks.get(&chunk_coord) {
|
self.heightmaps.insert(key, h);
|
||||||
let h = HeightMap::from_chunk(chunk);
|
|
||||||
self.heightmaps.insert(chunk_coord, h);
|
|
||||||
} else {
|
|
||||||
self.heightmaps.insert(chunk_coord, HeightMap::new());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.heightmaps.get(&chunk_coord).unwrap()
|
self.heightmaps.get(&key).unwrap()
|
||||||
}
|
|
||||||
|
|
||||||
/// Read-only heightmap fetch — returns a borrowed `Option<&HeightMap>`
|
|
||||||
/// without computing on miss. The bake path uses this after
|
|
||||||
/// `heightmap()` has populated the cache.
|
|
||||||
pub fn heightmap_get(&self, chunk_coord: IVec3) -> Option<&HeightMap> {
|
|
||||||
self.heightmaps.get(&chunk_coord)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// World-coords helper: get the topmost solid Y at world column
|
/// World-coords helper: get the topmost solid Y at world column
|
||||||
/// `(wx, wz)`. Returns `i32::MIN` if no solid (open sky all the
|
/// `(wx, wz)`. Returns `i32::MIN` if the column has no solid block
|
||||||
/// way down) or if the column's chunk hasn't been heightmapped.
|
/// or hasn't been heightmapped yet (caller should invoke
|
||||||
|
/// `heightmap(cx, cz)` first to warm the cache).
|
||||||
pub fn column_top_y(&self, wx: i32, wz: i32) -> i32 {
|
pub fn column_top_y(&self, wx: i32, wz: i32) -> i32 {
|
||||||
let (cc, lc) = Self::block_to_chunk(IVec3::new(wx, 0, wz));
|
let cx = wx.div_euclid(CHUNK_SIZE);
|
||||||
match self.heightmaps.get(&cc) {
|
let cz = wz.div_euclid(CHUNK_SIZE);
|
||||||
Some(h) => h.get_local(lc.x, lc.z),
|
let lx = wx.rem_euclid(CHUNK_SIZE);
|
||||||
|
let lz = wz.rem_euclid(CHUNK_SIZE);
|
||||||
|
match self.heightmaps.get(&IVec2::new(cx, cz)) {
|
||||||
|
Some(h) => h.get_local(lx, lz),
|
||||||
None => i32::MIN,
|
None => i32::MIN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split a world block position into (chunk_coord, local_coord).
|
||||||
|
/// Both axes are 3D now — chunks stack vertically as well as
|
||||||
|
/// horizontally.
|
||||||
pub fn block_to_chunk(pos: IVec3) -> (IVec3, IVec3) {
|
pub fn block_to_chunk(pos: IVec3) -> (IVec3, IVec3) {
|
||||||
let cx = pos.x.div_euclid(CHUNK_SIZE);
|
let cx = pos.x.div_euclid(CHUNK_SIZE);
|
||||||
|
let cy = pos.y.div_euclid(CHUNK_SIZE);
|
||||||
let cz = pos.z.div_euclid(CHUNK_SIZE);
|
let cz = pos.z.div_euclid(CHUNK_SIZE);
|
||||||
let lx = pos.x.rem_euclid(CHUNK_SIZE);
|
let lx = pos.x.rem_euclid(CHUNK_SIZE);
|
||||||
|
let ly = pos.y.rem_euclid(CHUNK_SIZE);
|
||||||
let lz = pos.z.rem_euclid(CHUNK_SIZE);
|
let lz = pos.z.rem_euclid(CHUNK_SIZE);
|
||||||
(IVec3::new(cx, 0, cz), IVec3::new(lx, pos.y, lz))
|
(IVec3::new(cx, cy, cz), IVec3::new(lx, ly, lz))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_block(&self, pos: IVec3) -> Block {
|
pub fn get_block(&self, pos: IVec3) -> Block {
|
||||||
if pos.y < 0 || pos.y >= CHUNK_HEIGHT {
|
|
||||||
return Block::Air;
|
|
||||||
}
|
|
||||||
let (c, l) = Self::block_to_chunk(pos);
|
let (c, l) = Self::block_to_chunk(pos);
|
||||||
match self.chunks.get(&c) {
|
match self.chunks.get(&c) {
|
||||||
Some(chunk) => chunk.get(l.x, l.y, l.z),
|
Some(chunk) => chunk.get(l.x, l.y, l.z),
|
||||||
|
|
@ -284,14 +308,15 @@ impl World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a block at `pos`. Creates the containing chunk if it doesn't
|
||||||
|
/// exist (so building straight up creates chunks on demand —
|
||||||
|
/// there's no fixed Y ceiling). Returns whether the world changed.
|
||||||
pub fn set_block(&mut self, pos: IVec3, b: Block) -> bool {
|
pub fn set_block(&mut self, pos: IVec3, b: Block) -> bool {
|
||||||
if pos.y < 0 || pos.y >= CHUNK_HEIGHT {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let (c, l) = Self::block_to_chunk(pos);
|
let (c, l) = Self::block_to_chunk(pos);
|
||||||
let Some(chunk) = self.chunks.get_mut(&c) else {
|
let chunk = self
|
||||||
return false;
|
.chunks
|
||||||
};
|
.entry(c)
|
||||||
|
.or_insert_with(|| Chunk::new(c));
|
||||||
chunk.set(l.x, l.y, l.z, b);
|
chunk.set(l.x, l.y, l.z, b);
|
||||||
// Mark neighbors dirty too so face culling is correct.
|
// Mark neighbors dirty too so face culling is correct.
|
||||||
for face in Face::ALL {
|
for face in Face::ALL {
|
||||||
|
|
@ -303,10 +328,8 @@ impl World {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Heightmap is now stale for this chunk; drop the cached
|
// Invalidate the column heightmap.
|
||||||
// entry so the next bake recomputes. Neighbor heightmaps
|
self.heightmaps.remove(&IVec2::new(c.x, c.z));
|
||||||
// are unaffected because columns are chunk-local.
|
|
||||||
self.heightmaps.remove(&c);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,15 +425,17 @@ fn value_noise(x: f32, z: f32) -> f32 {
|
||||||
a + (b - a) * v
|
a + (b - a) * v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The y of the topmost natural-terrain block (i.e. ignoring any
|
/// The y of the topmost natural-terrain block (ignoring player edits)
|
||||||
/// player-placed edits) at world column `(x, z)`. The block *at* this y
|
/// at world column `(x, z)`. Pure function of noise — no chunk lookup,
|
||||||
/// is solid; `y + 1` is the first air block above the natural surface.
|
/// no Y bound. The block AT this y is solid; `y + 1` is the first air
|
||||||
|
/// block above the natural surface. Same semantics as the
|
||||||
|
/// pre-cubic-chunks version so all collision/spawn tests still hold.
|
||||||
pub fn natural_surface_y(x: i32, z: i32) -> i32 {
|
pub fn natural_surface_y(x: i32, z: i32) -> i32 {
|
||||||
let wx = x as f32;
|
let wx = x as f32;
|
||||||
let wz = z as f32;
|
let wz = z as f32;
|
||||||
let n = fbm(wx * 0.04, wz * 0.04);
|
let n = fbm(wx * 0.04, wz * 0.04);
|
||||||
let height = (20.0 + n * 12.0).round() as i32;
|
let height = (20.0 + n * 12.0).round() as i32;
|
||||||
height.clamp(1, CHUNK_HEIGHT - 1) - 1
|
height - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fbm(x: f32, z: f32) -> f32 {
|
fn fbm(x: f32, z: f32) -> f32 {
|
||||||
|
|
@ -427,46 +452,70 @@ fn fbm(x: f32, z: f32) -> f32 {
|
||||||
sum / norm
|
sum / norm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Procedurally generate one cubic chunk. Determined entirely by the
|
||||||
|
/// chunk's world coord — the same coord always produces the same chunk.
|
||||||
|
/// Trees that straddle chunk boundaries deterministically fill in their
|
||||||
|
/// portion of this chunk; the neighboring chunk fills the rest.
|
||||||
fn generate_chunk(coord: IVec3) -> Chunk {
|
fn generate_chunk(coord: IVec3) -> Chunk {
|
||||||
let mut chunk = Chunk::new(coord);
|
let mut chunk = Chunk::new(coord);
|
||||||
let ox = coord.x * CHUNK_SIZE;
|
let ox = coord.x * CHUNK_SIZE;
|
||||||
|
let oy = coord.y * CHUNK_SIZE;
|
||||||
let oz = coord.z * CHUNK_SIZE;
|
let oz = coord.z * CHUNK_SIZE;
|
||||||
|
|
||||||
for x in 0..CHUNK_SIZE {
|
for x in 0..CHUNK_SIZE {
|
||||||
for z in 0..CHUNK_SIZE {
|
for z in 0..CHUNK_SIZE {
|
||||||
let wx = (ox + x) as f32;
|
// surface_y is the topmost SOLID block — same semantic
|
||||||
let wz = (oz + z) as f32;
|
// as `natural_surface_y`. Block at wy = surface_y is the
|
||||||
let n = fbm(wx * 0.04, wz * 0.04);
|
// grass/sand cap; wy > surface_y is air.
|
||||||
let height = (20.0 + n * 12.0).round() as i32;
|
let surface_y = natural_surface_y(ox + x, oz + z);
|
||||||
let height = height.clamp(1, CHUNK_HEIGHT - 1);
|
|
||||||
for y in 0..height {
|
// Terrain fill within this cubic chunk's Y range.
|
||||||
let b = if y == height - 1 {
|
for ly in 0..CHUNK_SIZE {
|
||||||
if height < 18 {
|
let wy = oy + ly;
|
||||||
Block::Sand
|
if wy > surface_y {
|
||||||
} else {
|
break;
|
||||||
Block::Grass
|
}
|
||||||
}
|
let b = if wy == surface_y {
|
||||||
} else if y > height - 4 {
|
if surface_y < 17 { Block::Sand } else { Block::Grass }
|
||||||
|
} else if wy > surface_y - 3 {
|
||||||
Block::Dirt
|
Block::Dirt
|
||||||
} else {
|
} else {
|
||||||
Block::Stone
|
Block::Stone
|
||||||
};
|
};
|
||||||
chunk.set(x, y, z, b);
|
chunk.set(x, ly, z, b);
|
||||||
}
|
}
|
||||||
// Occasional tree
|
|
||||||
|
// Trees, deterministic on (world_x, world_z). The trunk
|
||||||
|
// and leaves may span chunk boundaries; each chunk writes
|
||||||
|
// only the slice that falls within its own Y range. The
|
||||||
|
// neighboring chunk fills the rest using the same hash.
|
||||||
let tree_hash = hash2(ox + x + 1000, oz + z - 1000);
|
let tree_hash = hash2(ox + x + 1000, oz + z - 1000);
|
||||||
if tree_hash > 0.93 && height >= 18 && height < CHUNK_HEIGHT - 8 {
|
if tree_hash > 0.93 && surface_y >= 17 {
|
||||||
|
// Trunk: 4 wood blocks at (surface_y+1) .. (surface_y+5)
|
||||||
|
let trunk_base = surface_y + 1;
|
||||||
for ty in 0..4 {
|
for ty in 0..4 {
|
||||||
chunk.set(x, height + ty, z, Block::Wood);
|
let wy = trunk_base + ty;
|
||||||
|
if wy >= oy && wy < oy + CHUNK_SIZE {
|
||||||
|
chunk.set(x, wy - oy, z, Block::Wood);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Leaves: ball around y = trunk_base + 3
|
||||||
for dx in -2..=2_i32 {
|
for dx in -2..=2_i32 {
|
||||||
for dz in -2..=2_i32 {
|
for dz in -2..=2_i32 {
|
||||||
for dy in 3..=5_i32 {
|
for dy in 2..=4_i32 {
|
||||||
if dx.abs() + dz.abs() + (dy - 4).abs() <= 3 {
|
if dx.abs() + dz.abs() + (dy - 3).abs() <= 3 {
|
||||||
let lx = x + dx;
|
let lx = x + dx;
|
||||||
let lz = z + dz;
|
let lz = z + dz;
|
||||||
if lx >= 0 && lx < CHUNK_SIZE && lz >= 0 && lz < CHUNK_SIZE {
|
let wy = trunk_base + dy;
|
||||||
let ly = height + dy;
|
if lx >= 0
|
||||||
if ly < CHUNK_HEIGHT && chunk.get(lx, ly, lz) == Block::Air {
|
&& lx < CHUNK_SIZE
|
||||||
|
&& lz >= 0
|
||||||
|
&& lz < CHUNK_SIZE
|
||||||
|
&& wy >= oy
|
||||||
|
&& wy < oy + CHUNK_SIZE
|
||||||
|
{
|
||||||
|
let ly = wy - oy;
|
||||||
|
if chunk.get(lx, ly, lz) == Block::Air {
|
||||||
chunk.set(lx, ly, lz, Block::Leaves);
|
chunk.set(lx, ly, lz, Block::Leaves);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -494,11 +543,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn natural_surface_y_in_range() {
|
fn natural_surface_y_in_reasonable_range() {
|
||||||
|
// No more CHUNK_HEIGHT clamp — the surface is whatever the
|
||||||
|
// noise says. For our amplitude (20 ± 12) it should stay in
|
||||||
|
// a small band; we just sanity-check it's not absurd.
|
||||||
for x in -200..=200 {
|
for x in -200..=200 {
|
||||||
for z in -200..=200 {
|
for z in -200..=200 {
|
||||||
let y = natural_surface_y(x, z);
|
let y = natural_surface_y(x, z);
|
||||||
assert!((0..CHUNK_HEIGHT).contains(&y), "out of range at ({},{})", x, z);
|
assert!((0..64).contains(&y), "surface y={} out of expected band at ({},{})", y, x, z);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +562,7 @@ mod tests {
|
||||||
let origin = Vec3::new(0.5, surface as f32 + 12.0, 0.5);
|
let origin = Vec3::new(0.5, surface as f32 + 12.0, 0.5);
|
||||||
let hit = world.raycast(origin, Vec3::new(0.0, -1.0, 0.0), 30.0);
|
let hit = world.raycast(origin, Vec3::new(0.0, -1.0, 0.0), 30.0);
|
||||||
let (hit_pos, _) = hit.expect("ray fired down at terrain must hit");
|
let (hit_pos, _) = hit.expect("ray fired down at terrain must hit");
|
||||||
assert_eq!(hit_pos.y, surface, "ray must hit topmost solid block");
|
assert_eq!(hit_pos.y, surface, "ray must hit the topmost solid block (== surface)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -533,4 +585,26 @@ mod tests {
|
||||||
let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs();
|
let manhattan = delta.x.abs() + delta.y.abs() + delta.z.abs();
|
||||||
assert_eq!(manhattan, 1, "prev must be one block-step from hit");
|
assert_eq!(manhattan, 1, "prev must be one block-step from hit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_block_creates_chunks_above_pregenerated() {
|
||||||
|
// The pre-gen range stops at CHUNK_SIZE * (PRE_GEN_TOP_CHUNK_Y + 1) - 1.
|
||||||
|
// Placing a block well above that should succeed and create the chunk.
|
||||||
|
let mut world = World::new();
|
||||||
|
let high_y = (PRE_GEN_TOP_CHUNK_Y + 5) * CHUNK_SIZE + 3;
|
||||||
|
let pos = IVec3::new(0, high_y, 0);
|
||||||
|
let ok = world.set_block(pos, Block::Stone);
|
||||||
|
assert!(ok);
|
||||||
|
assert_eq!(world.get_block(pos), Block::Stone);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_block_creates_chunks_below_pregenerated() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let deep_y = (PRE_GEN_BOTTOM_CHUNK_Y - 5) * CHUNK_SIZE - 3;
|
||||||
|
let pos = IVec3::new(0, deep_y, 0);
|
||||||
|
let ok = world.set_block(pos, Block::Stone);
|
||||||
|
assert!(ok);
|
||||||
|
assert_eq!(world.get_block(pos), Block::Stone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue