From c140d995851383103c19041197df0894d197cf87 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Mon, 14 Sep 2026 22:36:11 +0100 Subject: [PATCH 1/2] Add connected cave biomes and lighting --- .github/workflows/tests.yml | 1 + ROADMAP.md | 14 +- assets/placeholders/caves/amethyst.png | Bin 0 -> 1450 bytes assets/placeholders/caves/amethyst.png.import | 40 ++ assets/placeholders/caves/calcite.png | Bin 0 -> 1157 bytes assets/placeholders/caves/calcite.png.import | 40 ++ assets/placeholders/caves/cave_moss.png | Bin 0 -> 558 bytes .../placeholders/caves/cave_moss.png.import | 40 ++ assets/placeholders/caves/crystal_bud.png | Bin 0 -> 313 bytes .../placeholders/caves/crystal_bud.png.import | 40 ++ assets/placeholders/caves/deepstone.png | Bin 0 -> 1125 bytes .../placeholders/caves/deepstone.png.import | 40 ++ assets/placeholders/caves/dripstone.png | Bin 0 -> 1152 bytes .../placeholders/caves/dripstone.png.import | 40 ++ assets/placeholders/caves/geode_shell.png | Bin 0 -> 1166 bytes .../placeholders/caves/geode_shell.png.import | 40 ++ assets/placeholders/caves/moss.png | Bin 0 -> 1293 bytes assets/placeholders/caves/moss.png.import | 40 ++ assets/placeholders/caves/sculk.png | Bin 0 -> 1343 bytes assets/placeholders/caves/sculk.png.import | 40 ++ autoload/audio_manager.gd | 7 +- autoload/game_config.gd | 2 +- game/main.gd | 19 +- tools/gen_underwater_textures.gd | 66 ++++ tools/worldgen_cave_verify.gd | 357 ++++++++++++++++++ tools/worldgen_cave_verify.gd.uid | 1 + world/block_registry.gd | 31 ++ world/chunk_mesher.gd | 117 +++--- world/day_night_cycle.gd | 28 ++ world/terrain_generator.gd | 8 + world/voxel_world.gd | 72 +++- world/worldgen/README.md | 55 ++- world/worldgen/biome_catalog.gd | 73 +++- world/worldgen/voxel_populator.gd | 349 ++++++++++++++--- world/worldgen/world_gen_config.gd | 2 +- 35 files changed, 1422 insertions(+), 140 deletions(-) create mode 100644 assets/placeholders/caves/amethyst.png create mode 100644 assets/placeholders/caves/amethyst.png.import create mode 100644 assets/placeholders/caves/calcite.png create mode 100644 assets/placeholders/caves/calcite.png.import create mode 100644 assets/placeholders/caves/cave_moss.png create mode 100644 assets/placeholders/caves/cave_moss.png.import create mode 100644 assets/placeholders/caves/crystal_bud.png create mode 100644 assets/placeholders/caves/crystal_bud.png.import create mode 100644 assets/placeholders/caves/deepstone.png create mode 100644 assets/placeholders/caves/deepstone.png.import create mode 100644 assets/placeholders/caves/dripstone.png create mode 100644 assets/placeholders/caves/dripstone.png.import create mode 100644 assets/placeholders/caves/geode_shell.png create mode 100644 assets/placeholders/caves/geode_shell.png.import create mode 100644 assets/placeholders/caves/moss.png create mode 100644 assets/placeholders/caves/moss.png.import create mode 100644 assets/placeholders/caves/sculk.png create mode 100644 assets/placeholders/caves/sculk.png.import create mode 100644 tools/worldgen_cave_verify.gd create mode 100644 tools/worldgen_cave_verify.gd.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75b6e59..53fdae5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -70,6 +70,7 @@ jobs: tools/player_target_verify.tscn \ tools/worldgen_cactus_verify.gd \ tools/worldgen_tree_verify.gd \ + tools/worldgen_cave_verify.gd \ tools/frame_pacing_verify.gd - name: Upload check logs diff --git a/ROADMAP.md b/ROADMAP.md index 56e010d..6255302 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,15 +150,15 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor - [ ] Structures / POIs — deterministic, seam-safe ruins, abandoned camps, mineshafts, and watchtowers placed from global anchors. Must respect player edits and regenerate consistently across chunks; loot and inventory hooks stay with the gameplay pass ### Caves and underground -- [x] Phase 1 — wormhole tunnels: connected cell-anchored tunnel segments and sparse seam-safe diagonal surface entrances replace the old per-voxel threshold caves. -- [x] Phase 2 — large caverns: cell-anchored varied caverns and rare mega-caves, lava lakes, aquifers, damp floor patches, and ceiling/floor stone formations. +- [x] Phase 1 — endless hybrid cave network: Minecraft/Luanti-inspired absolute-coordinate spaghetti and cheese density fields provide organic seam-free tunnels/chambers, while a five-depth-band deterministic 48-block graph guarantees that every canonical trunk continues indefinitely. Curved trunks, cross-links, mandatory vertical connectors, and node chambers replace the isolated 2-4-segment worms; surface entrances now join graph nodes instead of ending blindly in stone. +- [x] Phase 2 — large caverns: graph-attached chambers, cell-anchored varied caverns and rare mega-caves, lava lakes, aquifers, damp floor patches, and ceiling/floor stone formations. - [x] Phase 3 — underground features: damp mud/mycelium cave patches, depth-banded ore veins, aquifers, lava pockets, and stone formations. Random deep cobblestone variation was removed so cobblestone remains player-made; mineshafts/ruins remain separate future structure work. -- [ ] Cave-aware lighting and meshing — verify sky-light flood fill around large openings, that caves stay dark, and that `ChunkMesher` light-volume bounds hold for big caverns. -- [ ] Cave dressing — stalactites/stalagmites, dripstone columns, moss, and small underground pools so cave interiors are not bare stone -- [ ] Cave biomes — lush and deep-dark variants with distinct ambience, vegetation, and materials layered onto `world/worldgen/biome_catalog.gd` -- [ ] Geodes and crystal formations — rare hollow geodes and mineral deposits so deep exploration has landmarks beyond ore veins +- [x] Cave-aware lighting and meshing — `ChunkMesher.build_light_volume()` exposes the temporary 3x3 volume to deterministic fixtures; missing chunks are closed light boundaries rather than daylight leaks, water/leaf side seeds use their real attenuation, flood queues grow instead of silently truncating, and edits/commits invalidate all eight sampled neighbors (including pending-job races). `tools/worldgen_cave_verify.gd` pins sealed darkness, a large opening's finite falloff, colored crystal light, and exact cavern-volume bounds +- [x] Cave dressing — the sparse global cave lattice now finds nearby floors/ceilings and places paired dripstone, short columns, damp moss/mycelium, hanging cave growth, sculk/deepstone patches, and small floor-supported pools without returning to a full 3D voxel scan +- [x] Cave biomes — `BiomeCatalog` appends `LUSH_CAVES`/`DEEP_DARK` as a separate coherent 3D region layer (surface IDs stay stable); exposed cave materials and vegetation follow that layer, while `VoxelWorld.get_cave_ambience()` scans to the loaded column top so even tall mega-caves receive distinct green/teal fog and light floors. Cave regions cover most underground territory, and the HUD replaces the surface-biome label while one is active +- [x] Geodes and crystal formations — rare global-cell spheres clip seam-safely across chunks with a dark shell, calcite lining, hollow core, amethyst deposits, and emissive crystal buds that automatically seed the RGB block-light flood - [x] Ore distribution overhaul — deterministic cell-anchored coal, iron, and gold vein segments use depth bands and replace stone after caves are carved. -- [x] Cave performance guardrails — cell-anchored carving replaces full-height 3D cave scans; clipped ellipsoids hoist column limits, aquifer decisions are chunk-local, and normal full generation averages ~40 ms in the pinned CLI benchmark. +- [x] Cave performance guardrails — noise carving scans only eligible stone below each column's protected surface and Y100 cap; graph/cavern stamps clip to the target chunk from bounded owner halos; aquifer decisions remain chunk-local. The richer network raises pinned normal full generation from ~44 ms to ~58 ms while compact LOD remains ~16 ms. The cave verifier follows 128 trunk cells and flood-fills a 5x5 independently generated hybrid fixture: 6.9% air with a 32,128-block component spanning the full 80x92x80 volume. ### Verification - [x] Tree integrity / groves / cactus closure — fuller connected broadleaf crowns cover limb ends; immutable tree-site decisions and overlap regressions prevent chunk-owner/canopy disagreements. Forest, taiga, and jungle use a dedicated deterministic grove field with denser patches and clearings while respecting density controls. Cactus placeholder margins are cropped in runtime texture preparation to close cube edges/caps; opacity and stacked-mesh tests pass. Forward+ grove and cactus captures inspected. diff --git a/assets/placeholders/caves/amethyst.png b/assets/placeholders/caves/amethyst.png new file mode 100644 index 0000000000000000000000000000000000000000..8ed5ac787cdacd9874f5256ea7e274a8a23c2147 GIT binary patch literal 1450 zcmV;b1y%ZqP)Px)WJyFpRCt`_TR)3iM-U&KRT*3uK?15Gfzw%42nSb)BbA78f396bI@Gy2P?5q! zjk-g3A3+~M5C}qnYvMrQk<#emM&LqrafQoEVdiDuyqVeAefxB0@&m!?Y4^>}?EK%o z#!o-~atjW&yG{OgyVWf{qrfqBgIhxk?S5}i_}15_5ju;O|B@N-2*XYH7 z)3^uUefz`D7GgJ_KEk7ikMrNBlNo&V;0gTw&l>;$`0e*!;o0E}c=!G-006vu{}#Ua z`Uy+?k3Zk!;}FjdU$AtacD{!XH#?E3n@=C}zaKq(%s%ISM>MozCry5di^XaVC&#C?IyT&#v)8WxAlyN_n~Q&&d;s_SXAOJ1iTNld3cmzHVZIV=hCWLmYn=T3<&R)L~iLSGySiDH+_XdT&LX4y3 zdYkyHWCC@AdUgD8Kuxix(mu3XU6@X01$yS5pu5n0b%N^v^-pY1P=hN3t>gqKXK*2c ztIIJ`8kJ1I6-K+&EhR>-vx{MVv6>g2)9+OvW`dz;x4J=IeY@KPjbE(h`3J$x4-Kif zAz~&N1Bo$i=V;wvT{vfAbS{qR9d!yepCk&5ha>T~kPx+4%|(G}^$60CXt@H)uC5CQ zEiR%>z?b6sy20*3wQ*WRa_4Cqg3wH^DXCsesFP{{uG2%A*D5{gXWY5xmltA0qL@x* zc`1z$H6SJoxy<{tK-(2d3yuOKkw({Lp7jAzPc9Zdb4XPa(E5YYTxZKd*6C_nL%b3F3LbMF?Eirs~2{L+U^u z0Psv=Wp^JIUndR#^Fm{!Ne?Wsd4TBm28F^X_SicF*jlUe9wDMs z4pTxgDX*oWVNJ9`)8Wwcy%YwiddDOfmj645tstvpkX$G%31FQqOReU1t7~dM`-sJi zp-7!UdQD)T*hhs)k#~f+4sPhK$H1i#qY)HbQKlj4Y!GYf`*7KrA~bS%ugGvO#86n} zHkcI}jTE_rssUAcbNfJw>;3ocT%c}jcbkIW`Ckcu-ZcjB2*BFQ|5CsubxCiCe2GYY zQSz=4noNl%5RwGBW4Z3@T^*cI9=JLpxe44wI$MSQzf1I(iuONSDF6Tf07*qoM6N<$ Eg5u@9YXATM literal 0 HcmV?d00001 diff --git a/assets/placeholders/caves/amethyst.png.import b/assets/placeholders/caves/amethyst.png.import new file mode 100644 index 0000000..78814af --- /dev/null +++ b/assets/placeholders/caves/amethyst.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bssw0p7uk6sgb" +path="res://.godot/imported/amethyst.png-0cde5fa74b6905b31e22984ea15c58ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/caves/amethyst.png" +dest_files=["res://.godot/imported/amethyst.png-0cde5fa74b6905b31e22984ea15c58ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/caves/calcite.png b/assets/placeholders/caves/calcite.png new file mode 100644 index 0000000000000000000000000000000000000000..948cfef4d11484191314f8c3c57101d79a2a5017 GIT binary patch literal 1157 zcmV;01bX|4P)Px(KS@MERCt`#TfdI-Fbq!-4?$;!4t)d`1|FLMA+fV`;3Vd9%e?|Odc>{w%twM z*MJz2QpTSzx6ccLWWYGSChywCaga)u1u)V6`}uNnO-uziz{U67KKb1m!{aF$H^t<> z+tU(|yO^9wEnvyitv7bs1e7TK;$l>w)y#6AfMEV%O)4e7C zQ2}o4pTdXy#OQ}8%pZ; zT4YH8o-oy|fy7fpc=&soogK&5=cR#JHpoHZ63>?#89Z^o8=#3<0tw)ZO3hFf5+f09 zE6*{p0aRw_IjO1kPz-}Ro!gH{HDolNKx_lBUSnv)ma_|ex1Y=@VYOJi;7*hG-Rb6XsBv<&SEzx>A^!|muvWP zi9H<;!T4PMQZ*EYO-SfkYahMI8DA6%b0Q6c7il0fch8YiwWI_{i<&r);ff$R;24kZ z!8xU>6W9PsN41HEWlyFWOg|D5Aq@S>=Ii37D3 zuC&HU6F2sTx@ZY(bz_Dpmiy;OpvGOBwZ~I#?Q}e3MUx(yW4zJ=igQFH$Q3X(Qi2ro5Gx` z#i=sxAI_!%u@hUho==EXji5@Mj)(aUNW>PE^ZOc-^O1m9GO@HLs6Br(+^_GL{^vFs z{sICUfQiTPzttmkejZI;P-=)d?=i6fv0DC;4^ihFww4rH@%L;an44~vLfS>vo=X1( XvmPvlxbx@?00000NkvXXu0mjfFccD! literal 0 HcmV?d00001 diff --git a/assets/placeholders/caves/calcite.png.import b/assets/placeholders/caves/calcite.png.import new file mode 100644 index 0000000..9436101 --- /dev/null +++ b/assets/placeholders/caves/calcite.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs4hm0mylpa6q" +path="res://.godot/imported/calcite.png-7c0ce0733faa21e689a38ff8d7030e82.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/caves/calcite.png" +dest_files=["res://.godot/imported/calcite.png-7c0ce0733faa21e689a38ff8d7030e82.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/caves/cave_moss.png b/assets/placeholders/caves/cave_moss.png new file mode 100644 index 0000000000000000000000000000000000000000..53857a7ba006d790e0d3e745f77c11dd7dbec507 GIT binary patch literal 558 zcmV+}0@3}6P)Px$=t)FDRCt{2n9FU1Fc5}em0r?83do6Laz{!DxkeO#a!m;$8DWva?B45MIb$V2_u&dcwv89-xF%tgUP+5l;a7vMqK04|`R zR=fw80N8>3*v`upGH&gTp<_A7#smHn+;nLJUPs2>3|hLCyoPy*(ANWfeP~wG3+N6^>Bd z2c!@XEWkAVxGR>f8R})Ox36bW&u{`r#h^5*s{jRO9;-SBq#()zDDuIYt{LhjeEPAS z_qG3QJ&Zl-J%GD~W*0zva#ZgD+>llQXbi4v|MmSQdP>}QfChJ^Y`7pK=>t-22E!i# zG#blkQ}IdA1dujB8WTX;0BKABX#+Tcbe!x0q-uMy>udx#nne31Vz}!NfIuby*W2U% zY79CIy*5wo_qDo3E%-fsxDCBLexih;6c~LP+;2qs`@Rpzi_7M-_}OOm_o1FfCICg4 zVCRSltBnBE280-P^z7Ou4qO$WNKVwOZQ{cE0BHlHxk~{;!Z~{ZVsjU%0N70L`#)7u wzF-!EWM_?4#mA`!XxIp+4cX^_Sc(_m7d3D*S)ns(B>(^b07*qoM6N<$g7wnv>Hq)$ literal 0 HcmV?d00001 diff --git a/assets/placeholders/caves/cave_moss.png.import b/assets/placeholders/caves/cave_moss.png.import new file mode 100644 index 0000000..c337a94 --- /dev/null +++ b/assets/placeholders/caves/cave_moss.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djj7jv32u30f4" +path="res://.godot/imported/cave_moss.png-5201fb2f2914988116317446b7226b2a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/caves/cave_moss.png" +dest_files=["res://.godot/imported/cave_moss.png-5201fb2f2914988116317446b7226b2a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/caves/crystal_bud.png b/assets/placeholders/caves/crystal_bud.png new file mode 100644 index 0000000000000000000000000000000000000000..5365b2f254e694fcd2ed63e1d0e0b5250da9b676 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D$|K6|=2hE&XX zJN+Q-oR?*^~>o|bH^^Ank@ja?#n z4kr_p^SE{~+Opg}DtbexNStw70#siwV+NxX^93f5`E?)vR{mai?Ej(r3_F$9ws<$^ nI;>2HeqK_|8kGoDewOLv{$`KqdbVmn?=yJ1`njxgN@xNAUmPx(A4x<(RCt`-Td|HJF%0#t=vl2a#Y}Yt(g}%b|Nk?PXsDq;LdzH2Rh|pRj_ueP z*n1L!3^PgW*vWI8dHePI>km8;!#K^qFXs~g0NlQ>^R@NI=UutHo`Lt2S{hLbk7X(> zCwx|W08dsx1YX6U@KgyuMgg1c?ykK4_=I7c=8pz+FZ@QND8SPZu2WJO;GeG_5BrBU zYh_D&00679KqsFiPf)O?i+wb}0Ac-Y@ffb<>;uj{V#TVmjdjupql{y`vA2L2ayjjjst z&eT+(QuuP@Q3;ap9;KV-fZO+V{?Jb2#{#KCcpd9$U4=YKAI7P_3vjrcPYt7(KdLAl>_lyLH+kOYjt>;kPOsV$L`iE`AJmNUp&iVJW; zNDQwh!=x6+^@YNvfR@g4;cdj4v_93`j)SNGY2v7%^jqs1T^qGbMFH^3qc{gdga`0( zcS}?lg|s||=YV0HRC;Pb51}mx1w*LC9?R2G2q^$Z&0_&QR)Lmy0Q(+}25X(DaeGUB zmiwbi?5u!{wK{9P_try|<#|4JM@S2dtfuM>h$P`HZH89?s%wFIb{?HW%QYdPn%i`r{K= zCYT{a2IbXq2rsiElz|$b;P77@kU((ktu}#XF41=iko;R(zAO_>1kzSE2dmrC`$QM0 z@oRct&rMHlw7L9&7K@bRE5!x8*&*)vS~&|`khS7&cm|dVe&}_R1z1L?HuQ8|@{O(HhF9FhMN~6iJ6!(Mpa? z3P9m%^wi25UJdFbSPGMMGRuNiX6J=E2WTwN^PG2|UwcktpxkjDie@IL36phzL4vjv zTP>rS9>;H*_U;9Z+0i+uW&zgpylE1Q3hha7)KYBt-)6hJJ7OD&O1ta_WJiZoDQJ+P}G z5uAz_uZEQUfKoVaWL{NxfEUROX`o8YOp`w~H|X(gDs;

Px(I!Q!9RCt`#TfMF0Fbw7kJVJnU0n()L5N@w^eQVranIS{CNfRJlfb4MB91WQ! zDN?3v=K~&I{40^-zv!PozkmIO0u0YL^j$k1KVF~l_w{^a&!^Y9^J{>P-B=#D-G|xv z_Hq*M*9OGvTQGqe`(SxM!sGQB|I%~x9`1N6;_0y{0XgC&49_DZEF6*HjL3R@Y5_gR?i$_1i+-iF>YR)sY~rxClwK7THiKhzI!3089N$>-^ZF zF6e>gftvIt%Zv7ah^P=TvjNdt-b+El=jb_2Y(S|V*J4!7IshUp6!tJ-FSY?7I2Z8N z0=59H6-*f5>DE{}2@7&PhY}=J;l|B2NR)yGAc#l{PN9X!Qb1NM9qzjp+RJGcF>(>) z8lVjCyB4nJW1a!Hv*WSAX-}?YG7*r*O3%~G7QopnY-Pe0{lJ!R9bhhj?p|SQF$8-I z>ASXSlczvi)YQt>YS;a_h!vTuEmFEL0Kstp+KSrYbS`4`NSVk0t4?u1J&PtD864?J zR0)ticd>}|9ug@DuH9j7cen3awttv1Y%Swg!tAj_L@R(=dhn%Aa&P8lX?01*h?200 z3M@%K>AfZWyjx_6BLg(QPj^|QT^EiCQd_%3^x8~lEUVmJPAt)-)|h_2vSk&@90zd8 zL@A`*nwsgi)HC2}m6axVyANX7S;AViha6M}Xav!a((`rco-knm_fb@ILjt{ySfl!c zW1!u;-3Mr-3XSxg9rxmI1kx=U!JJ`QTf9pOGaAr{t2SUMk$dPi)yC+%7XAT_wDT+8 Sw9-ZZ0000Px(NJ&INRCt`#TfL4NK@472JOF|oMIw|rrMc+JJU$W~Eux{n)4f4bdE+(yAJ2yS zBy`6zfA)CBpFOjypTBU)%cW8; z0DWooBCeCRV<~I1-OVk)J42SBnVI0~aYEzu>xDL+5&QTGKOJ3@P@bzt$8o=1)9p)` z@c`@|qDr82wGtWnjG^|P=) z59qAJ*s&s_Bq<#WEDbLKicV;m0P3l=#S4>7;5>Fs6aHQk%|R4^OIOvCWAu} zWtoxvDZB(Y^TW{@Mz!$pz0xIysIbRf;)4hH8#zSrqN<(pO^_QHJxbHC6tM!B3WyA( zha2t9Khz~aGcOg~JA?b}T7HKCpjrTzvbHuLE0^y%Q=o~?$bm-`<|fE#TbcnXJm&?X zvzJMRL=q)#FW~XTSYT=6>v?{Fl?DHsNm1>`$Xy(c18|8-`2H-(*l`$Y*HkqmMR{(r zXwLzo#Nr|xe(7XLq>RNu*5KMK9xt66;;2tze9>z!93&TP!Qu^KPS6qxmx**3fp#-R z^F%<$L*Xz5S2;sbv*EC`ae59zBh*{<0&pImUE}z6hu1Q+zC7L|8hE*Gib@Spp)Tjs zOn@aXMsn8ak!{Ke_Q=tBev`^c;Oa*&PT}(ItZ+|w_m-&eQePO;9?)t5y_sD`c=z%6 z{}?==<=!9WFj;y%W`(yhH(t~9QeCUQ7Cvk3rjEz8;Kqu`dL=e6BG+aXy&s^F8~nXz zzqEb#3lo`gz{pycLrEpG90prjf#^(rQNTLeDZSVaY7 zcX!-T=58-V=0&RrC7u*OJMg$C%@tbpJsF+MK`YnJ%j=t{gb_E4JFsTDB~>j6Puq}! zEswHjJqaVH<*4x1a|@DH41*m};*8Y(a8z>v-1x@oqo-)n1O(6q-t)D*DJ2$pFQ>w< zOD5|T*4hs^@52$T>D^)MlP0BMNpBme*a07*qoM6N<$f(qFwkpKVy literal 0 HcmV?d00001 diff --git a/assets/placeholders/caves/geode_shell.png.import b/assets/placeholders/caves/geode_shell.png.import new file mode 100644 index 0000000..839cfee --- /dev/null +++ b/assets/placeholders/caves/geode_shell.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bser0d1y3div3" +path="res://.godot/imported/geode_shell.png-ed3ef6bbc110e2cc5e9efdcd8ec05818.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/caves/geode_shell.png" +dest_files=["res://.godot/imported/geode_shell.png-ed3ef6bbc110e2cc5e9efdcd8ec05818.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/caves/moss.png b/assets/placeholders/caves/moss.png new file mode 100644 index 0000000000000000000000000000000000000000..2c6450b29164dca8eb51a787581034e2b9ae436f GIT binary patch literal 1293 zcmV+o1@iidP)Px($4Nv%RCt`tTTNZS4{!B<3U6tP%h+uLF95n(&!$)F#mP0lKXn~} z8tp(K6l=iK97 z?l4$p7>UvS_xm#wNHOA3?OcIGn2TfvskO4hpx4oqGH$b59`OVhwjw@bFZty};=Fo9h4)J~kI zsi7s?GRB#w@BDKP{&_C|%Oi8N{xKv6)YF&Xq$NgGiAzdS zbre!b9d02a^|c(|*$*QgXMqI@GDaCJslzp-im7O1MFIXh0RN8LX9>7UmkPquMFs#E zmk?;3$Z+O)O`p4tb`1b9$tfICutHr#&yNbkdkCrf)Kr*1IFwCl^hzpE4<-P1Ygy0SsF5V*vzFFc>pK#0rVZfpG^&klJ1bzfE{nUgW8w(hLTd^ z8fxiM`qnkTKMmk2??{0Q}1#VxV_5eiGgGCgF~32OE#odBH* z8@j_=Vgkvgs5KlZg~+Iqm9MLzblTK&IHQN(KhK+^Na!l2a8%Q;zGaJ60x-Yxc)&DI3DI4GGT{m70m z+OpxL06zE4_ZKoN ztT7#Nn$jBDn2iU#npFi*<76azjvl7;CT6L~Bnm)lL3`DakIK=0W33P*bMy2uIdFej zQ6NVXwCS(tTp_idByg&s?mU1Z*Ca}uq!tcy1!z^Y^W{|k#|XEj5}!Z`BS8Px(`AI}URCt`#Tdi^%K@3(hMlncvL0~~A+`w-U5)u>`obgOSfQvH5hDIhKeTx9| zJV748z($^FK}T1eENLa}o|AqvH>usbJ*`&%lFz<;`~Dk}IP7=g_oIt(c1hPeqU+N& z__ODwhsQU!p_lz{II|>xZhrUcrTe#b@*I@ghyAYV}^~w=llBRc#m2=aK-;7LNm@BUX*B?LCP*{3NWV4=7*xF=7y1e&t8x>Z8>}hLg#egiDWrG061lQIR zslkmH-Uxb;pttw|U}t2Kk@BoVpFb6f>9ACZ@f0Br`yE`~-NSahI=S16Kve-6EqS9n ziqXZ`z4@UND~onj0;0xAy6-cfWVFRNz>1tKADtnJ5|^z!SwLE~LkUU1bUE+*dTlk9 z#`D|tsw;VG7%JsXsX*i&30e0z1QV*ffEETzqTH&-s70q)yQAg;o~{sOgoU9Stdeo| zGDLKPTCt+W@L8Y?w(567peF$}{X1&ZJi02Ct;RRECpUy013cB5Qi`epH%C7M9IF}M zVAL&U4zFVX^_LpK8a%2&*YMf|?Y)?@w8%`;&C#s7Xq)$(u=FezIkF5$r^7A7UCp)e z%R<)Ee9G3Cr`aieA4%sC&E^9lkh)HuM2j=wdU7Rt4P*k=0x5Wg+O#5sD5bYjuQ3Cu zEk{3lI#+21Ocpa$?}nHO0DvCTsN3X!BWSJynNnD!;w0T)Le-$8w3yYbv_4a44OdT0 z5v?FuMb$xUKnb2rN<>eWa~NHWRTayW*1{?vRfDbR3_e>RiQ`9BhL0K}wF!qdSfL@B zU$icpb*(WZmX^J;&_uje0<;d6cWOjp*f~J|q+TDMLU}(Y%G$j2m+I|wITzO{O^}q{ zkIzf%R2f0Ou$OW4l&G}P0S0bYBC$Td*NPL)GkS#XiUuW==nUxTQ&8eLt(J}9^F!J` zM*y)NKR;z2TSu7S)qOf*<1wt;^-4{iI9$`>l#rG#D-!WV>yh3P-C)VkXZfj3^JK^~ z74DrMY~AnxkHE&Wz4l156y*O>60(H`)&;Yaqq} z*%8%@m`dZcwMEg=3?oH25X#<{LCxd`UkAb0`vV~g6Z1n3J-Pq@002ovPDHLkV1k;T BekK3_ literal 0 HcmV?d00001 diff --git a/assets/placeholders/caves/sculk.png.import b/assets/placeholders/caves/sculk.png.import new file mode 100644 index 0000000..31334b6 --- /dev/null +++ b/assets/placeholders/caves/sculk.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c0xj215b3gir5" +path="res://.godot/imported/sculk.png-45b15ed072b442481575b13b599840a2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/caves/sculk.png" +dest_files=["res://.godot/imported/sculk.png-45b15ed072b442481575b13b599840a2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/autoload/audio_manager.gd b/autoload/audio_manager.gd index be8d710..d1e1425 100644 --- a/autoload/audio_manager.gd +++ b/autoload/audio_manager.gd @@ -104,12 +104,15 @@ func _build_block_materials() -> void: BlockRegistry.BLOCK_BROWN_MUSHROOM, BlockRegistry.BLOCK_RED_MUSHROOM, BlockRegistry.BLOCK_MYCELIUM, BlockRegistry.BLOCK_MELON, BlockRegistry.BLOCK_SEAGRASS, BlockRegistry.BLOCK_KELP, BlockRegistry.BLOCK_CORAL_FAN, BlockRegistry.BLOCK_CORAL_BRANCH, BlockRegistry.BLOCK_SPONGE, - BlockRegistry.BLOCK_ANEMONE], + BlockRegistry.BLOCK_ANEMONE, BlockRegistry.BLOCK_CAVE_MOSS], "dirt": [BlockRegistry.BLOCK_DIRT, BlockRegistry.BLOCK_CLAY, BlockRegistry.BLOCK_MUD], "stone": [BlockRegistry.BLOCK_STONE, BlockRegistry.BLOCK_COBBLESTONE, BlockRegistry.BLOCK_BEDROCK, BlockRegistry.BLOCK_COAL_ORE, BlockRegistry.BLOCK_IRON_ORE, BlockRegistry.BLOCK_GOLD_ORE, BlockRegistry.BLOCK_TERRACOTTA, BlockRegistry.BLOCK_GLOWSTONE, BlockRegistry.BLOCK_GLASS, - BlockRegistry.BLOCK_TORCH, BlockRegistry.BLOCK_CORAL_SUBSTRATE], + BlockRegistry.BLOCK_TORCH, BlockRegistry.BLOCK_CORAL_SUBSTRATE, BlockRegistry.BLOCK_DRIPSTONE, + BlockRegistry.BLOCK_MOSS, BlockRegistry.BLOCK_DEEPSTONE, BlockRegistry.BLOCK_SCULK, + BlockRegistry.BLOCK_CALCITE, BlockRegistry.BLOCK_GEODE_SHELL, BlockRegistry.BLOCK_AMETHYST, + BlockRegistry.BLOCK_CRYSTAL_BUD], "sand": [BlockRegistry.BLOCK_SAND, BlockRegistry.BLOCK_RED_SAND], "snow": [BlockRegistry.BLOCK_SNOW], "gravel": [BlockRegistry.BLOCK_GRAVEL], diff --git a/autoload/game_config.gd b/autoload/game_config.gd index 892273b..5de5de7 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -165,7 +165,7 @@ const DEFAULT_WORLD := { "world_type": 0, "terrain_scale": 1.0, "tree_density": 1.0, - "worldgen_version": 6, + "worldgen_version": 8, "macro_scale": 384.0, "biome_scale": 3072.0, "river_density": 1.0, diff --git a/game/main.gd b/game/main.gd index baaa422..d87dbaf 100644 --- a/game/main.gd +++ b/game/main.gd @@ -19,6 +19,7 @@ const SLOT_ICON := 30.0 const UNDERWATER_SAMPLE_INTERVAL := 0.15 const UNDERWATER_FADE_SECONDS := 0.35 const UNDERWATER_DEEP_COLOR := Color(0.02, 0.08, 0.16) +const CAVE_FADE_SECONDS := 0.65 const DYNAMIC_RESOLUTION_INTERVAL := 0.5 const DYNAMIC_RESOLUTION_SMOOTHING := 0.2 @@ -50,6 +51,11 @@ var _underwater_depth := 0.0 var _underwater_tint := Color.WHITE var _underwater_target := 0.0 var _underwater_sample_time := 0.0 +var _cave_amount := 0.0 +var _cave_target := 0.0 +var _cave_depth := 0.0 +var _cave_biome := BiomeCatalog.CAVE_BIOME_NONE +var _cave_tint := Color("#242936") var _worldgen_overlay: WorldgenOverlay var _photo_mode: PhotoMode var _minimap: Minimap @@ -228,8 +234,16 @@ func _update_underwater(delta: float) -> void: _underwater_depth = clampf(float(ambience["depth"]) / DayNightCycle.UNDERWATER_MAX_DEPTH, 0.0, 1.0) if _underwater_target > 0.0: _underwater_tint = ambience["tint"] as Color + var cave := world.get_cave_ambience(view.global_position) + _cave_target = 1.0 if bool(cave["active"]) and _underwater_target <= 0.0 else 0.0 + _cave_depth = float(cave["depth"]) + if _cave_target > 0.0: + _cave_biome = int(cave["biome"]) + _cave_tint = cave["tint"] as Color _underwater_amount = move_toward(_underwater_amount, _underwater_target, delta / UNDERWATER_FADE_SECONDS) + _cave_amount = move_toward(_cave_amount, _cave_target, delta / CAVE_FADE_SECONDS) _day_night.set_underwater(_underwater_amount, _underwater_depth, _underwater_tint) + _day_night.set_cave_ambience(_cave_amount, _cave_depth, _cave_biome, _cave_tint) _underwater_overlay.visible = _underwater_amount > 0.001 if _underwater_overlay.visible: var tinted := _underwater_tint.lerp(UNDERWATER_DEEP_COLOR, _underwater_depth * 0.75) @@ -789,9 +803,12 @@ func _update_stats() -> void: var view := _active_camera() var camera_position := view.global_position if view != null else player.global_position var coords := Vector3i(floori(camera_position.x), floori(camera_position.y), floori(camera_position.z)) + var location_name := world.get_biome_name(camera_position) + if _cave_amount > 0.35 and BiomeCatalog.is_cave_biome(_cave_biome): + location_name = BiomeCatalog.cave_display_name(_cave_biome) _coords_label.text = "X %d Y %d Z %d\n%s" % [ coords.x, coords.y, coords.z, - world.get_biome_name(camera_position), + location_name, ] _stats_label.text = "%d FPS\n%d chunks\n%s" % [ Engine.get_frames_per_second(), diff --git a/tools/gen_underwater_textures.gd b/tools/gen_underwater_textures.gd index 85067b9..3b4ba4e 100644 --- a/tools/gen_underwater_textures.gd +++ b/tools/gen_underwater_textures.gd @@ -6,12 +6,14 @@ extends SceneTree const SIZE := 64 const OUTPUT_DIR := "res://assets/placeholders/underwater" +const CAVE_OUTPUT_DIR := "res://assets/placeholders/caves" var _rng := RandomNumberGenerator.new() func _initialize() -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAVE_OUTPUT_DIR)) _rng.seed = 0xC0A1 _save("coral_substrate.png", _coral_substrate()) _save("sponge.png", _sponge()) @@ -20,6 +22,15 @@ func _initialize() -> void: _save("coral_fan.png", _coral_fan()) _save("coral_branch.png", _coral_branch()) _save("anemone.png", _anemone()) + _save_cave("dripstone.png", _stone_texture(Color("#745b48"), Color("#9a795c"), Color("#493a31"))) + _save_cave("moss.png", _stone_texture(Color("#416637"), Color("#668d4c"), Color("#293f29"))) + _save_cave("deepstone.png", _stone_texture(Color("#202732"), Color("#35404c"), Color("#111720"))) + _save_cave("sculk.png", _speckled_texture(Color("#10272b"), Color("#1b7a83"), Color("#48b2a2"))) + _save_cave("calcite.png", _stone_texture(Color("#d5d0c2"), Color("#eee9da"), Color("#aaa99f"))) + _save_cave("geode_shell.png", _stone_texture(Color("#303640"), Color("#4e5965"), Color("#20242d"))) + _save_cave("amethyst.png", _speckled_texture(Color("#684b86"), Color("#a779cf"), Color("#d5b4f0"))) + _save_cave("cave_moss.png", _cave_moss()) + _save_cave("crystal_bud.png", _crystal_bud()) print("UNDERWATER TEXTURES: done") quit() @@ -39,6 +50,61 @@ func _save(name: String, image: Image) -> void: print("saved ", path) +func _save_cave(name: String, image: Image) -> void: + var path := "%s/%s" % [CAVE_OUTPUT_DIR, name] + var error := image.save_png(path) + if error != OK: + push_error("failed to save %s (%d)" % [path, error]) + else: + print("saved ", path) + + +func _stone_texture(base: Color, light: Color, dark: Color) -> Image: + var image := _new_image() + image.fill(base) + for index in 110: + var color := light if index % 3 == 0 else dark + _disc(image, _rng.randi_range(0, SIZE - 1), _rng.randi_range(0, SIZE - 1), _rng.randf_range(0.8, 3.4), color) + return image + + +func _speckled_texture(base: Color, middle: Color, bright: Color) -> Image: + var image := _stone_texture(base, middle, base.darkened(0.25)) + for index in 42: + var x := _rng.randi_range(1, SIZE - 2) + var y := _rng.randi_range(1, SIZE - 2) + _put(image, x, y, bright) + if index % 3 == 0: + _put(image, x + 1, y, middle) + return image + + +func _cave_moss() -> Image: + var image := _new_image() + var colors := [Color("#86b85c"), Color("#5f9142"), Color("#b0d978")] + for strand in 11: + var x := 4 + strand * 5 + _rng.randi_range(-1, 1) + var top := _rng.randi_range(5, 28) + _blade(image, float(x), top, SIZE - 1, 1, _rng.randf_range(1.0, 2.5), colors) + return image + + +func _crystal_bud() -> Image: + var image := _new_image() + var dark := Color("#66458f") + var body := Color("#a06ed1") + var light := Color("#dfc1ff") + for crystal in 5: + var center := 11 + crystal * 10 + _rng.randi_range(-2, 2) + var top := _rng.randi_range(7, 28) + for y in range(top, SIZE): + var t := float(y - top) / float(SIZE - top) + var half_width := maxi(1, roundi(3.0 * t)) + for x in range(center - half_width, center + half_width + 1): + _put(image, x, y, light if x == center - half_width else (dark if x == center + half_width else body)) + return image + + func _put(image: Image, x: int, y: int, color: Color) -> void: if x >= 0 and x < SIZE and y >= 0 and y < SIZE: image.set_pixel(x, y, color) diff --git a/tools/worldgen_cave_verify.gd b/tools/worldgen_cave_verify.gd new file mode 100644 index 0000000..2f407a1 --- /dev/null +++ b/tools/worldgen_cave_verify.gd @@ -0,0 +1,357 @@ +extends SceneTree + +const TEST_CONFIG := { + "seed": 123456789, + "world_type": 0, + "terrain_scale": 1.0, + "tree_density": 0.0, + "macro_scale": 384.0, + "river_density": 1.0, + "erosion_strength": 0.55, + "regional_erosion": 0.5, + "cave_density": 1.0, + "decoration_density": 1.0, +} + +var _failures := PackedStringArray() +var _registry: BlockRegistry +var _mesher: ChunkMesher + + +func _initialize() -> void: + _registry = BlockRegistry.new() + _mesher = ChunkMesher.new(_registry) + _verify_cave_catalog() + _verify_endless_cave_network() + _verify_cave_dressing() + _verify_surface_exclusion() + _verify_geode() + _verify_cave_lighting() + if _failures.is_empty(): + print("WORLDGEN CAVE VERIFY: PASS") + quit(0) + return + for failure in _failures: + push_error(failure) + print("WORLDGEN CAVE VERIFY: FAIL (", _failures.size(), ")") + quit(1) + + +func _verify_cave_catalog() -> void: + var catalog := BiomeCatalog.new() + _expect(catalog.name_for(BiomeCatalog.LUSH_CAVES) == "lush_caves", "lush cave biome was not appended to BiomeCatalog") + _expect(catalog.name_for(BiomeCatalog.DEEP_DARK) == "deep_dark", "deep-dark biome was not appended to BiomeCatalog") + var found_lush := false + var found_deep := false + var middle_regions := 0 + var middle_lush := 0 + for cell_z in range(-8, 9): + for cell_x in range(-8, 9): + var x := cell_x * BiomeCatalog.CAVE_REGION_SIZE + 16 + var z := cell_z * BiomeCatalog.CAVE_REGION_SIZE + 16 + var middle_biome := BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), x, 50, z) + found_lush = found_lush or middle_biome == BiomeCatalog.LUSH_CAVES + found_deep = found_deep or BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), x, 24, z) == BiomeCatalog.DEEP_DARK + middle_regions += 1 + if middle_biome == BiomeCatalog.LUSH_CAVES: + middle_lush += 1 + _expect(found_lush, "cave classifier produced no lush regions") + _expect(found_deep, "cave classifier produced no deep-dark regions") + _expect(float(middle_lush) / float(middle_regions) >= 0.65, "lush cave regions are too sparse to discover reliably") + _expect(not BiomeCatalog.is_cave_biome(BiomeCatalog.PLAINS), "surface biome was classified as a cave biome") + + +func _verify_endless_cave_network() -> void: + var setup := _worldgen_services() + var populator: VoxelPopulator = setup.populator + var sampler: TerrainSampler = setup.sampler + # The canonical trunk advances one global macro cell on every step. This is + # the topology guarantee: unlike the legacy 2-4 segment worms, it cannot end. + var cell := Vector2i.ZERO + var visited_cells := {} + for step in 128: + visited_cells[cell] = true + var route_hash := WorldGenHash.hash_3d(int(TEST_CONFIG.seed) + 719, cell.x, 2, cell.y) + cell += Vector2i(1, 0) if (route_hash & 1) == 0 else Vector2i(0, 1) + _expect(visited_cells.size() == 128 and cell.x + cell.y == 128, + "canonical cave trunk terminated or looped instead of continuing globally") + + # Carve the hybrid field independently in 25 chunks, then flood it as one volume. + # A large component spanning the region proves stamps reproduce across chunk + # borders and form an explorable network rather than isolated local pockets. + const chunk_radius := 2 + const width := (chunk_radius * 2 + 1) * VoxelDefs.CHUNK_SIZE + const depth := width + const height := 96 + var cave_air := PackedByteArray() + cave_air.resize(width * depth * height) + for chunk_z in range(-chunk_radius, chunk_radius + 1): + for chunk_x in range(-chunk_radius, chunk_radius + 1): + var chunk_pos := Vector2i(chunk_x, chunk_z) + var field := sampler.build_field(chunk_pos) + _set_field_height(field, 100.0) + var data := _solid_fixture(100) + populator._carve_noise_caves(data, field, chunk_x * VoxelDefs.CHUNK_SIZE, chunk_z * VoxelDefs.CHUNK_SIZE) + populator._carve_cave_network(data, field, chunk_x * VoxelDefs.CHUNK_SIZE, chunk_z * VoxelDefs.CHUNK_SIZE) + for y in range(4, height): + for local_z in VoxelDefs.CHUNK_SIZE: + for local_x in VoxelDefs.CHUNK_SIZE: + if _block(data, local_x, y, local_z) != BlockRegistry.BLOCK_AIR: + continue + var volume_x := (chunk_x + chunk_radius) * VoxelDefs.CHUNK_SIZE + local_x + var volume_z := (chunk_z + chunk_radius) * VoxelDefs.CHUNK_SIZE + local_z + cave_air[(y * depth + volume_z) * width + volume_x] = 1 + var metrics := _largest_air_component(cave_air, width, height, depth) + var cave_share := float(cave_air.count(1)) / float(cave_air.size()) + _expect(cave_share >= 0.025, "hybrid cave field is too sparse to find through ordinary exploration") + _expect(cave_share <= 0.24, "hybrid cave field removes too much underground terrain") + _expect(int(metrics.count) >= 2500, "largest cave network component is too small to sustain exploration") + _expect(maxi(int(metrics.span_x), int(metrics.span_z)) >= width - 2, + "cave network does not remain connected across the generated multi-chunk region") + _expect(int(metrics.span_y) >= 24, "cave network has no meaningful vertical connections") + print("WORLDGEN CAVE NETWORK: air=", snappedf(cave_share * 100.0, 0.01), "% component=", metrics.count, + " span=", metrics.span_x, "x", metrics.span_y, "x", metrics.span_z) + + +func _verify_cave_dressing() -> void: + var setup := _worldgen_services() + var populator: VoxelPopulator = setup.populator + var sampler: TerrainSampler = setup.sampler + for cave_biome in [BiomeCatalog.LUSH_CAVES, BiomeCatalog.DEEP_DARK]: + var chunk_pos := _find_cave_chunk(cave_biome) + var field := sampler.build_field(chunk_pos) + _set_field_height(field, 100.0) + var data := _room_fixture(68, 9, 59) + populator._decorate_caves(data, field, chunk_pos.x * VoxelDefs.CHUNK_SIZE, chunk_pos.y * VoxelDefs.CHUNK_SIZE) + if cave_biome == BiomeCatalog.LUSH_CAVES: + var lush_surface_blocks := data.count(BlockRegistry.BLOCK_MOSS) + data.count(BlockRegistry.BLOCK_CAVE_MOSS) + _expect(lush_surface_blocks >= 24, "lush cave material patches are too sparse to read visually") + _expect(data.count(BlockRegistry.BLOCK_CAVE_MOSS) > 0, "lush cave dressing placed no vegetation") + else: + var deep_surface_blocks := data.count(BlockRegistry.BLOCK_DEEPSTONE) + data.count(BlockRegistry.BLOCK_SCULK) + _expect(deep_surface_blocks >= 24, "deep-dark material patches are too sparse to read visually") + _expect(data.count(BlockRegistry.BLOCK_SCULK) > 0, "deep-dark dressing placed no sculk") + var normal_chunk := Vector2i.ZERO + var normal_field := sampler.build_field(normal_chunk) + _set_field_height(normal_field, 100.0) + var normal_data := _room_fixture(52, 9, 43) + populator._decorate_caves(normal_data, normal_field, 0, 0) + _expect(normal_data.count(BlockRegistry.BLOCK_DRIPSTONE) > 0, "cave dressing placed no stalactites/stalagmites") + _expect(normal_data.count(BlockRegistry.BLOCK_WATER) > 0, "cave dressing placed no small underground pools") + + +func _verify_surface_exclusion() -> void: + var setup := _worldgen_services() + var populator: VoxelPopulator = setup.populator + var sampler: TerrainSampler = setup.sampler + var field := sampler.build_field(Vector2i.ZERO) + _set_field_height(field, 30.0) + var ocean := PackedByteArray() + ocean.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for y in range(31): + for column in VoxelDefs.CHUNK_AREA: + ocean[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_STONE + for y in range(31, VoxelDefs.SEA_LEVEL + 1): + for column in VoxelDefs.CHUNK_AREA: + ocean[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_WATER + var unchanged := ocean.duplicate() + populator._decorate_caves(ocean, field, 0, 0) + _expect(ocean == unchanged, "cave dressing escaped onto the ocean surface") + + _set_field_height(field, 52.0) + var land := PackedByteArray() + land.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for y in range(53): + for column in VoxelDefs.CHUNK_AREA: + land[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_STONE + var land_unchanged := land.duplicate() + populator._decorate_caves(land, field, 0, 0) + _expect(land == land_unchanged, "cave dressing escaped onto open land") + + +func _verify_geode() -> void: + var setup := _worldgen_services() + var populator: VoxelPopulator = setup.populator + var sampler: TerrainSampler = setup.sampler + var field := sampler.build_field(Vector2i.ZERO) + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for y in range(0, 64): + for column in VoxelDefs.CHUNK_AREA: + data[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_BEDROCK if y == 0 else BlockRegistry.BLOCK_STONE + populator._stamp_geode(data, field, Vector3i(8, 28, 8), 7, 991) + _expect(data.count(BlockRegistry.BLOCK_GEODE_SHELL) > 0, "geode has no outer shell") + _expect(data.count(BlockRegistry.BLOCK_CALCITE) > 0, "geode has no calcite lining") + _expect(data.count(BlockRegistry.BLOCK_AMETHYST) > 0, "geode has no amethyst deposits") + _expect(data.count(BlockRegistry.BLOCK_CRYSTAL_BUD) > 0, "geode has no crystal formations") + _expect(_block(data, 8, 28, 8) == BlockRegistry.BLOCK_AIR, "geode center is not hollow") + _expect(_block(data, 8, 0, 8) == BlockRegistry.BLOCK_BEDROCK, "geode overwrote bedrock") + + +func _verify_cave_lighting() -> void: + var sealed := _room_fixture(42, 8, 30) + var sealed_heights := _heights(sealed, 42) + var closed_volume := _mesher.build_light_volume(sealed, 42, sealed_heights, ChunkMesher.NeighborSet.new()) + _expect(closed_volume.w == VoxelDefs.CHUNK_SIZE * 3 and closed_volume.d == VoxelDefs.CHUNK_SIZE * 3, + "cave light footprint is not the documented 3x3 chunks") + _expect(closed_volume.h == 45, "cave light volume height did not keep the max-y padding") + _expect(closed_volume.blocks.size() == closed_volume.w * closed_volume.d * closed_volume.h, + "large-cavern light-volume bounds do not match storage") + _expect(_sky(closed_volume, ChunkMesher.LIGHT_PAD + 8, 18, ChunkMesher.LIGHT_PAD + 8) == 0, + "sealed cave received sky light through a missing neighbor") + + # A west-neighbor opening illuminates a connected large cavern. At fourteen + # horizontal steps it retains level one; the finite 15-level flood cannot + # influence cells farther away, proving the one-chunk pad is sufficient. + var west := _room_fixture(42, 8, 30) + for y in range(30, 43): + west[15 + 8 * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y] = BlockRegistry.BLOCK_AIR + var neighbors := ChunkMesher.NeighborSet.new() + neighbors.samples[Vector2i(-1, 0)] = ChunkMesher.NeighborSample.new(west, 42, _heights(west, 42)) + var open_volume := _mesher.build_light_volume(sealed, 42, sealed_heights, neighbors) + var near_level := _sky(open_volume, ChunkMesher.LIGHT_PAD, 29, ChunkMesher.LIGHT_PAD + 8) + var far_level := _sky(open_volume, ChunkMesher.LIGHT_PAD + 14, 29, ChunkMesher.LIGHT_PAD + 8) + _expect(near_level >= 14, "large cave opening did not seed skylight into the connected cavern") + _expect(far_level == 0, "sky light propagated beyond its finite 15-level reach") + var wet_cave := sealed.duplicate() + wet_cave[0 + 8 * VoxelDefs.DATA_STRIDE_Z + 29 * VoxelDefs.DATA_STRIDE_Y] = BlockRegistry.BLOCK_WATER + var wet_volume := _mesher.build_light_volume(wet_cave, 42, sealed_heights, neighbors) + _expect(_sky(wet_volume, ChunkMesher.LIGHT_PAD, 29, ChunkMesher.LIGHT_PAD + 8) == 12, + "side-lit cave water did not apply its three-level attenuation") + + # Rows above a committed neighbor's max_y are known air, while a genuinely + # absent tile stays stone. Confusing the two creates false light walls at + # borders between a low chunk and a taller center chunk. + var low_neighbor := _room_fixture(10, 4, 8) + var height_neighbors := ChunkMesher.NeighborSet.new() + height_neighbors.samples[Vector2i(-1, 0)] = ChunkMesher.NeighborSample.new(low_neighbor, 10, _heights(low_neighbor, 10)) + var height_volume := _mesher.build_light_volume(sealed, 42, sealed_heights, height_neighbors) + _expect(height_volume.blocks[_volume_index(height_volume, ChunkMesher.LIGHT_PAD - 1, 20, ChunkMesher.LIGHT_PAD + 8)] == BlockRegistry.BLOCK_AIR, + "known neighbor air above max_y became an artificial solid light wall") + _expect(height_volume.blocks[_volume_index(height_volume, ChunkMesher.LIGHT_PAD * 3 - 1, 20, ChunkMesher.LIGHT_PAD + 8)] == BlockRegistry.BLOCK_STONE, + "missing neighbor stopped acting as a closed light boundary") + + var crystal_data := sealed.duplicate() + crystal_data[8 + 8 * VoxelDefs.DATA_STRIDE_Z + 18 * VoxelDefs.DATA_STRIDE_Y] = BlockRegistry.BLOCK_CRYSTAL_BUD + var crystal_volume := _mesher.build_light_volume(crystal_data, 42, sealed_heights, ChunkMesher.NeighborSet.new()) + var emitter_index := _volume_index(crystal_volume, ChunkMesher.LIGHT_PAD + 8, 18, ChunkMesher.LIGHT_PAD + 8) + _expect(not crystal_volume.block_b.is_empty() and crystal_volume.block_b[emitter_index] > 0, + "crystal formation did not seed colored block light") + + +func _worldgen_services() -> Dictionary: + var config := WorldGenConfig.new(TEST_CONFIG) + var biomes := BiomeCatalog.new() + var sampler := TerrainSampler.new(config, TerrainProfileCatalog.new(), biomes) + return {"populator": VoxelPopulator.new(config, biomes, sampler), "sampler": sampler} + + +func _find_cave_chunk(target: int) -> Vector2i: + var y := 50 if target == BiomeCatalog.LUSH_CAVES else 24 + for cell_z in range(-10, 11): + for cell_x in range(-10, 11): + var world_x := cell_x * BiomeCatalog.CAVE_REGION_SIZE + 16 + var world_z := cell_z * BiomeCatalog.CAVE_REGION_SIZE + 16 + if BiomeCatalog.cave_biome_at(int(TEST_CONFIG.seed), world_x, y, world_z) == target: + return Vector2i(WorldGenHash.floor_div(world_x, VoxelDefs.CHUNK_SIZE), WorldGenHash.floor_div(world_z, VoxelDefs.CHUNK_SIZE)) + _failures.append("could not find cave biome %d fixture region" % target) + return Vector2i.ZERO + + +func _room_fixture(max_y: int, floor_y: int, ceiling_y: int) -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for y in range(max_y + 1): + for column in VoxelDefs.CHUNK_AREA: + data[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_BEDROCK if y == 0 else BlockRegistry.BLOCK_STONE + for y in range(floor_y + 1, ceiling_y): + for z in VoxelDefs.CHUNK_SIZE: + for x in VoxelDefs.CHUNK_SIZE: + data[x + z * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y] = BlockRegistry.BLOCK_AIR + return data + + +func _solid_fixture(max_y: int) -> PackedByteArray: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + for y in range(max_y + 1): + for column in VoxelDefs.CHUNK_AREA: + data[y * VoxelDefs.DATA_STRIDE_Y + column] = BlockRegistry.BLOCK_BEDROCK if y == 0 else BlockRegistry.BLOCK_STONE + return data + + +func _largest_air_component(cave_air: PackedByteArray, width: int, height: int, depth: int) -> Dictionary: + var visited := PackedByteArray() + visited.resize(cave_air.size()) + var largest := {"count": 0, "span_x": 0, "span_y": 0, "span_z": 0} + var directions: Array[Vector3i] = [Vector3i.LEFT, Vector3i.RIGHT, Vector3i.DOWN, Vector3i.UP, Vector3i.FORWARD, Vector3i.BACK] + for start in cave_air.size(): + if cave_air[start] == 0 or visited[start] != 0: + continue + var queue := PackedInt32Array([start]) + visited[start] = 1 + var cursor := 0 + var count := 0 + var min_pos := Vector3i(width, height, depth) + var max_pos := Vector3i.ZERO + while cursor < queue.size(): + var current := queue[cursor] + cursor += 1 + var x: int = current % width + var yz: int = current / width + var z: int = yz % depth + var y: int = yz / depth + count += 1 + min_pos = min_pos.min(Vector3i(x, y, z)) + max_pos = max_pos.max(Vector3i(x, y, z)) + for direction in directions: + var neighbor: Vector3i = Vector3i(x, y, z) + direction + if neighbor.x < 0 or neighbor.x >= width or neighbor.y < 0 or neighbor.y >= height or neighbor.z < 0 or neighbor.z >= depth: + continue + var neighbor_index: int = (neighbor.y * depth + neighbor.z) * width + neighbor.x + if cave_air[neighbor_index] == 0 or visited[neighbor_index] != 0: + continue + visited[neighbor_index] = 1 + queue.append(neighbor_index) + if count > int(largest.count): + largest = { + "count": count, + "span_x": max_pos.x - min_pos.x + 1, + "span_y": max_pos.y - min_pos.y + 1, + "span_z": max_pos.z - min_pos.z + 1, + } + return largest + + +func _set_field_height(field: ChunkTerrainData, height: float) -> void: + for index in field.final_height.size(): + field.final_height[index] = height + + +func _heights(data: PackedByteArray, max_y: int) -> PackedInt32Array: + var heights := PackedInt32Array() + heights.resize(VoxelDefs.CHUNK_AREA) + heights.fill(-1) + for column in VoxelDefs.CHUNK_AREA: + for y in range(max_y, -1, -1): + if data[column + y * VoxelDefs.DATA_STRIDE_Y] != BlockRegistry.BLOCK_AIR: + heights[column] = y + break + return heights + + +func _block(data: PackedByteArray, x: int, y: int, z: int) -> int: + return data[x + z * VoxelDefs.DATA_STRIDE_Z + y * VoxelDefs.DATA_STRIDE_Y] + + +func _volume_index(volume: ChunkMesher.LightVolume, x: int, y: int, z: int) -> int: + return (y * volume.d + z) * volume.w + x + + +func _sky(volume: ChunkMesher.LightVolume, x: int, y: int, z: int) -> int: + return volume.sky[_volume_index(volume, x, y, z)] + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/worldgen_cave_verify.gd.uid b/tools/worldgen_cave_verify.gd.uid new file mode 100644 index 0000000..3462f85 --- /dev/null +++ b/tools/worldgen_cave_verify.gd.uid @@ -0,0 +1 @@ +uid://cjy6awe4et05w diff --git a/world/block_registry.gd b/world/block_registry.gd index 1054615..0b91322 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -62,6 +62,15 @@ const BLOCK_SPONGE := 57 const BLOCK_ANEMONE := 58 const BLOCK_TNT := 59 const BLOCK_NUKE := 60 +const BLOCK_DRIPSTONE := 61 +const BLOCK_MOSS := 62 +const BLOCK_CAVE_MOSS := 63 +const BLOCK_DEEPSTONE := 64 +const BLOCK_SCULK := 65 +const BLOCK_CALCITE := 66 +const BLOCK_GEODE_SHELL := 67 +const BLOCK_AMETHYST := 68 +const BLOCK_CRYSTAL_BUD := 69 const FLAG_OPAQUE := 1 const FLAG_CUTOUT := 2 @@ -133,6 +142,15 @@ const BLOCK_DEFS := [ [58, "ANEMONE", "anemone.png", "anemone.png", "anemone.png", FLAG_CUTOUT | FLAG_CROSS], [59, "TNT", "tnt_top.png", "tnt_side.png", "tnt_bottom.png", FLAG_OPAQUE], [60, "NUKE", "nuke_top.png", "nuke_side.png", "nuke_bottom.png", FLAG_OPAQUE], + [61, "DRIPSTONE", "dripstone.png", "dripstone.png", "dripstone.png", FLAG_OPAQUE], + [62, "MOSS", "moss.png", "moss.png", "moss.png", FLAG_OPAQUE], + [63, "CAVE MOSS", "cave_moss.png", "cave_moss.png", "cave_moss.png", FLAG_CUTOUT | FLAG_CROSS], + [64, "DEEPSTONE", "deepstone.png", "deepstone.png", "deepstone.png", FLAG_OPAQUE], + [65, "SCULK", "sculk.png", "sculk.png", "sculk.png", FLAG_OPAQUE | FLAG_EMISSIVE], + [66, "CALCITE", "calcite.png", "calcite.png", "calcite.png", FLAG_OPAQUE], + [67, "GEODE SHELL", "geode_shell.png", "geode_shell.png", "geode_shell.png", FLAG_OPAQUE], + [68, "AMETHYST", "amethyst.png", "amethyst.png", "amethyst.png", FLAG_OPAQUE | FLAG_EMISSIVE], + [69, "CRYSTAL BUD", "crystal_bud.png", "crystal_bud.png", "crystal_bud.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], ] const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" @@ -140,6 +158,7 @@ const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" ## upstream directory stays an unmodified copy. const UNDERWATER_TEXTURE_ROOT := "res://assets/placeholders/underwater/" const EXPLOSIVE_TEXTURE_ROOT := "res://assets/placeholders/explosives/" +const CAVE_TEXTURE_ROOT := "res://assets/placeholders/caves/" const EXTRA_TEXTURE_PATHS := { "coral_substrate.png": UNDERWATER_TEXTURE_ROOT + "coral_substrate.png", "seagrass.png": UNDERWATER_TEXTURE_ROOT + "seagrass.png", @@ -154,6 +173,15 @@ const EXTRA_TEXTURE_PATHS := { "nuke_top.png": EXPLOSIVE_TEXTURE_ROOT + "nuke_top.png", "nuke_side.png": EXPLOSIVE_TEXTURE_ROOT + "nuke_side.png", "nuke_bottom.png": EXPLOSIVE_TEXTURE_ROOT + "nuke_bottom.png", + "dripstone.png": CAVE_TEXTURE_ROOT + "dripstone.png", + "moss.png": CAVE_TEXTURE_ROOT + "moss.png", + "cave_moss.png": CAVE_TEXTURE_ROOT + "cave_moss.png", + "deepstone.png": CAVE_TEXTURE_ROOT + "deepstone.png", + "sculk.png": CAVE_TEXTURE_ROOT + "sculk.png", + "calcite.png": CAVE_TEXTURE_ROOT + "calcite.png", + "geode_shell.png": CAVE_TEXTURE_ROOT + "geode_shell.png", + "amethyst.png": CAVE_TEXTURE_ROOT + "amethyst.png", + "crystal_bud.png": CAVE_TEXTURE_ROOT + "crystal_bud.png", } const WATER_TEXTURE_PATH := TEXTURE_ROOT + "water.png" const WATER_SHADER_PATH := "res://assets/placeholders/zigcraft/water.gdshader" @@ -171,6 +199,9 @@ const EMISSIVE_COLORS := { BLOCK_GLOWSTONE: Color(1.0, 0.78, 0.52), BLOCK_TORCH: Color(0.93, 0.68, 0.44), BLOCK_LAVA: Color(1.0, 0.3, 0.06), + BLOCK_SCULK: Color(0.08, 0.34, 0.42), + BLOCK_AMETHYST: Color(0.48, 0.24, 0.72), + BLOCK_CRYSTAL_BUD: Color(0.72, 0.42, 1.0), } const ATTENUATION_LEAVES := 3 # Light drops 3 per water block so sky light stops fading in within ~5 blocks of diff --git a/world/chunk_mesher.gd b/world/chunk_mesher.gd index 084a120..3209369 100644 --- a/world/chunk_mesher.gd +++ b/world/chunk_mesher.gd @@ -494,15 +494,35 @@ func _assemble_light_volume(data: PackedByteArray, data_max_y: int, data_heights for tile_x in 3: var index := tile_z * 3 + tile_x var tile := tiles[index] - if not tile.is_empty() and y <= tile_max[index]: + if tile.is_empty(): + # Unknown neighboring terrain is a closed light boundary, not open + # sky. Treating an unloaded tile as air leaks daylight sideways into + # caves until that neighbor commits and is especially visible in + # large caverns near the streaming edge. + for _local_x in VoxelDefs.CHUNK_SIZE: + volume.blocks.push_back(BlockRegistry.BLOCK_STONE) + elif y <= tile_max[index]: var source := y * VoxelDefs.DATA_STRIDE_Y + local_z * VoxelDefs.DATA_STRIDE_Z volume.blocks.append_array(tile.slice(source, source + VoxelDefs.CHUNK_SIZE)) else: - volume.blocks.resize(volume.blocks.size() + VoxelDefs.CHUNK_SIZE) + # An existing tile's rows above max_y are known air. Making these + # rows solid creates false walls between differently sized chunks. + for _local_x in VoxelDefs.CHUNK_SIZE: + volume.blocks.push_back(BlockRegistry.BLOCK_AIR) volume.sky.resize(volume.blocks.size()) return volume +## Verification/debug entry point. Production build() clears its temporary +## light volume after baking vertex attributes; this method intentionally keeps +## it so fixtures can assert cave-mouth falloff, sealed darkness, and bounds. +func build_light_volume(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, neighbors: NeighborSet) -> LightVolume: + var volume := _assemble_light_volume(data, data_max_y, heights, neighbors) + _compute_sky_light(volume) + _compute_block_light(volume) + return volume + + ## Expands compact LOD columns into a full tile so the light volume and face ## culling treat distance chunks exactly like generated ones. func _expand_lod_tile(sample: NeighborSample) -> PackedByteArray: @@ -545,9 +565,7 @@ func _compute_sky_light(volume: LightVolume) -> void: var sky := volume.sky var heights := volume.heights var queue := PackedInt32Array() - queue.resize(blocks.size() * 2) var head := 0 - var tail := 0 for volume_z in volume.d: for vx in w: var column := volume_z * w + vx @@ -567,31 +585,31 @@ func _compute_sky_light(volume: LightVolume) -> void: break if sky[index] < level: sky[index] = level - if level > 1 and tail < queue.size(): + if level > 1: if vx > 0: var ni := index - 1 - if sky[ni] < level - 1 and _opacity[blocks[ni]] < MAX_LEVEL: - sky[ni] = level - 1 - queue[tail] = ni - tail += 1 + var next_level := level - maxi(_opacity[blocks[ni]], 1) + if sky[ni] < next_level: + sky[ni] = next_level + queue.push_back(ni) if vx < w - 1: var ni := index + 1 - if sky[ni] < level - 1 and _opacity[blocks[ni]] < MAX_LEVEL: - sky[ni] = level - 1 - queue[tail] = ni - tail += 1 + var next_level := level - maxi(_opacity[blocks[ni]], 1) + if sky[ni] < next_level: + sky[ni] = next_level + queue.push_back(ni) if volume_z > 0: var ni := index - w - if sky[ni] < level - 1 and _opacity[blocks[ni]] < MAX_LEVEL: - sky[ni] = level - 1 - queue[tail] = ni - tail += 1 + var next_level := level - maxi(_opacity[blocks[ni]], 1) + if sky[ni] < next_level: + sky[ni] = next_level + queue.push_back(ni) if volume_z < volume.d - 1: var ni := index + w - if sky[ni] < level - 1 and _opacity[blocks[ni]] < MAX_LEVEL: - sky[ni] = level - 1 - queue[tail] = ni - tail += 1 + var next_level := level - maxi(_opacity[blocks[ni]], 1) + if sky[ni] < next_level: + sky[ni] = next_level + queue.push_back(ni) y -= 1 if top + 1 < volume.h: for direction in 4: @@ -611,19 +629,18 @@ func _compute_sky_light(volume: LightVolume) -> void: var neighbor_top := mini(int(heights[nz * w + nx]), volume.h) if neighbor_top <= top + 1: continue - var seed_level := MAX_LEVEL - 1 var neighbor_y := top + 1 while neighbor_y < neighbor_top: var ni := neighbor_y * area + nz * w + nx - if _opacity[blocks[ni]] >= MAX_LEVEL: + var attenuation := _opacity[blocks[ni]] + if attenuation >= MAX_LEVEL: break + var seed_level := MAX_LEVEL - maxi(attenuation, 1) if sky[ni] < seed_level: sky[ni] = seed_level - if tail < queue.size(): - queue[tail] = ni - tail += 1 + queue.push_back(ni) neighbor_y += 1 - while head < tail: + while head < queue.size(): var index := queue[head] head += 1 var level := sky[index] @@ -636,45 +653,39 @@ func _compute_sky_light(volume: LightVolume) -> void: if vx > 0: var ni := index - 1 var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) if vx < w - 1: var ni := index + 1 var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) if volume_z > 0: var ni := index - w var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) if volume_z < volume.d - 1: var ni := index + w var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) if y > 0: var ni := index - area var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) if y < volume.h - 1: var ni := index + area var nl := level - maxi(_opacity[blocks[ni]], 1) - if nl > sky[ni] and tail < queue.size(): + if nl > sky[ni]: sky[ni] = nl - queue[tail] = ni - tail += 1 + queue.push_back(ni) func _compute_block_light(volume: LightVolume) -> void: @@ -695,9 +706,7 @@ func _compute_block_light(volume: LightVolume) -> void: var green := volume.block_g var blue := volume.block_b var queue := PackedInt32Array() - queue.resize(size * 2) var head := 0 - var tail := 0 var volume_z := 0 var y := 0 var vx := 0 @@ -719,9 +728,7 @@ func _compute_block_light(volume: LightVolume) -> void: red[emitter] = maxi(red[emitter], seed_r) green[emitter] = maxi(green[emitter], seed_g) blue[emitter] = maxi(blue[emitter], seed_b) - if tail < queue.size(): - queue[tail] = emitter - tail += 1 + queue.push_back(emitter) elif seed_r > 1 or seed_g > 1 or seed_b > 1: var neighbor_r := maxi(seed_r - 1, 0) var neighbor_g := maxi(seed_g - 1, 0) @@ -760,11 +767,9 @@ func _compute_block_light(volume: LightVolume) -> void: red[ni] = maxi(red[ni], neighbor_r) green[ni] = maxi(green[ni], neighbor_g) blue[ni] = maxi(blue[ni], neighbor_b) - if tail < queue.size(): - queue[tail] = ni - tail += 1 + queue.push_back(ni) emitter = blocks.find(block_id, emitter + 1) - while head < tail: + while head < queue.size(): var index := queue[head] head += 1 var r := red[index] @@ -814,9 +819,7 @@ func _compute_block_light(volume: LightVolume) -> void: red[ni] = maxi(red[ni], nr) green[ni] = maxi(green[ni], ng) blue[ni] = maxi(blue[ni], nb) - if tail < queue.size(): - queue[tail] = ni - tail += 1 + queue.push_back(ni) func _build_ao_offsets() -> void: diff --git a/world/day_night_cycle.gd b/world/day_night_cycle.gd index 3fa4e19..b903018 100644 --- a/world/day_night_cycle.gd +++ b/world/day_night_cycle.gd @@ -71,6 +71,13 @@ const UNDERWATER_VOLUMETRIC_SCALE := 1.4 const UNDERWATER_LIGHT_FLOOR := 0.32 const UNDERWATER_CAUSTIC_STRENGTH := 0.9 const UNDERWATER_FALLBACK_COLOR := Color(0.06, 0.24, 0.36) +# Cave-region grading is deliberately subtler in lush caves and nearly removes +# outdoor ambient light in deep dark. Emissive sculk/crystals remain readable +# through the baked block-light channel. +const CAVE_FOG_ADD := 0.012 +const CAVE_VOLUMETRIC_SCALE := 1.22 +const LUSH_CAVE_LIGHT_FLOOR := 0.48 +const DEEP_DARK_LIGHT_FLOOR := 0.16 # Lightning is a short additive flash on the sun/ambient and fog; `WeatherSystem` # schedules the strikes and `Main` calls trigger_lightning(). const LIGHTNING_DECAY := 2.6 @@ -104,6 +111,10 @@ var base_contrast := 1.04 var underwater_amount := 0.0 var underwater_depth := 0.0 var underwater_color := UNDERWATER_FALLBACK_COLOR +var cave_amount := 0.0 +var cave_depth := 0.0 +var cave_biome := BiomeCatalog.CAVE_BIOME_NONE +var cave_color := Color("#242936") var lightning_flash := 0.0 var wind_strength := 0.55 var _sky_material: ShaderMaterial @@ -152,6 +163,13 @@ func set_underwater(amount: float, depth: float, tint: Color = UNDERWATER_FALLBA underwater_color = tint +func set_cave_ambience(amount: float, depth: float, biome: int, tint: Color) -> void: + cave_amount = clampf(amount, 0.0, 1.0) + cave_depth = clampf(depth, 0.0, 1.0) + cave_biome = biome + cave_color = tint + + func get_clock_text() -> String: var hours := int(time_hours) var minutes := int((time_hours - float(hours)) * 60.0) @@ -210,6 +228,16 @@ func _apply() -> void: _sun.light_energy *= lerpf(1.0, UNDERWATER_LIGHT_FLOOR, submerged) if _fill_light: _fill_light.light_energy *= lerpf(1.0, UNDERWATER_LIGHT_FLOOR, submerged) + if cave_amount > 0.001 and underwater_amount < 0.5: + var cave_mix := cave_amount * lerpf(0.65, 1.0, cave_depth) + var light_floor := DEEP_DARK_LIGHT_FLOOR if cave_biome == BiomeCatalog.DEEP_DARK else LUSH_CAVE_LIGHT_FLOOR + environment.fog_light_color = environment.fog_light_color.lerp(cave_color, cave_mix) + environment.fog_density += CAVE_FOG_ADD * cave_mix + environment.volumetric_fog_density *= lerpf(1.0, CAVE_VOLUMETRIC_SCALE, cave_mix) + environment.ambient_light_energy *= lerpf(1.0, light_floor, cave_mix) + _sun.light_energy *= lerpf(1.0, light_floor, cave_mix) + if _fill_light: + _fill_light.light_energy *= lerpf(1.0, light_floor, cave_mix) RenderingServer.global_shader_parameter_set("sky_light_strength", lerpf(NIGHT_SKY_LIGHT, 1.0, day_factor) * weather_factor) RenderingServer.global_shader_parameter_set("wind_strength", wind_strength * lerpf(1.0, WIND_RAIN_SCALE, weather_dim)) RenderingServer.global_shader_parameter_set("underwater_caustics", diff --git a/world/terrain_generator.gd b/world/terrain_generator.gd index 37e856b..239fcca 100644 --- a/world/terrain_generator.gd +++ b/world/terrain_generator.gd @@ -148,6 +148,14 @@ func water_tint_for(biome_id: int) -> Color: return _biomes.water_tint(biome_id) +## Deterministic underground-region lookup shared by population and the +## throttled camera ambience query. The caller decides whether the voxel is +## actually inside a cave before applying this 3D region label. +func cave_biome_id_at(world_x: int, y: int, world_z: int) -> int: + _ensure_configured() + return BiomeCatalog.cave_biome_at(_config.seed, world_x, y, world_z) + + func biome_color(biome_id: int) -> Color: _ensure_configured() match biome_id: diff --git a/world/voxel_world.gd b/world/voxel_world.gd index bda9033..991d2f8 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -22,7 +22,7 @@ const COMMIT_BUDGET_MS := 5 const DEBUG_MAP_MAX_RESOLUTION := 128 const DEBUG_MAP_HEAVY_RESOLUTION := 64 const DEBUG_MAP_HEAVY_MODES: Array[String] = ["height", "raw_height", "slope"] -const REBUILD_OPPOSITE_BITS := [2, 1, 8, 4] +const REBUILD_OPPOSITE_BITS := [2, 1, 8, 4, 128, 64, 32, 16] const WATER_TICK_INTERVAL := 0.25 const WATER_CELLS_PER_TICK := 1024 const TNT_BLAST_RADIUS := 5 @@ -409,8 +409,7 @@ func _gather_neighbors(pos: Vector2i) -> ChunkMesher.NeighborSet: chunk.lod_sub_id.duplicate(), chunk.lod_water_y.duplicate()) else: out.samples[direction] = ChunkMesher.NeighborSample.new(chunk.data.duplicate(), chunk.max_y, chunk.heights.duplicate()) - if index < 4: - out.mask |= (1 << index) + out.mask |= (1 << index) return out @@ -469,12 +468,13 @@ func _create_chunk_nodes(pos: Vector2i) -> Chunk: func _remesh_on_commit_neighbors(pos: Vector2i) -> void: - for index in VoxelDefs.DIRS_4.size(): - var neighbor: Chunk = _chunks.get(pos + VoxelDefs.DIRS_4[index]) - if neighbor == null: + for index in VoxelDefs.DIRS_8.size(): + var direction: Vector2i = VoxelDefs.DIRS_8[index] + var neighbor: Chunk = _chunks.get(pos + direction) + if neighbor == null or neighbor.lod: continue if (neighbor.mask & REBUILD_OPPOSITE_BITS[index]) == 0: - _queue_rebuild(pos + VoxelDefs.DIRS_4[index]) + _queue_rebuild(pos + direction, true) func _queue_rebuild(pos: Vector2i, preserve_if_pending := false) -> void: @@ -573,6 +573,46 @@ func get_water_ambience(world_position: Vector3) -> Dictionary: } +## Cave-biome ambience is only active in loaded full-detail underground air. +## A bounded ceiling probe prevents a region label from grading the surface or +## a broad entrance; it runs beside the already-throttled underwater query. +func get_cave_ambience(world_position: Vector3) -> Dictionary: + var position := Vector3i(floori(world_position.x), floori(world_position.y), floori(world_position.z)) + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod or position.y < 4: + return {"active": false, "biome": BiomeCatalog.CAVE_BIOME_NONE, "tint": Color.WHITE, "depth": 0.0} + var block_id := get_block_world(position) + if block_id != BlockRegistry.BLOCK_AIR and not _blocks.has_flag(block_id, BlockRegistry.FLAG_CROSS): + return {"active": false, "biome": BiomeCatalog.CAVE_BIOME_NONE, "tint": Color.WHITE, "depth": 0.0} + # Mega-caves can be more than 32 blocks tall. Probe to the loaded column's + # actual top so their ambience does not disappear merely because the ceiling + # is high above the camera. First require real terrain depth: a tree canopy + # or player roof over a surface camera must never activate cave grading. + var chunk_position := _chunk_for_block(position) + var local_x := position.x - chunk_position.x * VoxelDefs.CHUNK_SIZE + var local_z := position.z - chunk_position.y * VoxelDefs.CHUNK_SIZE + var column := local_x + local_z * VoxelDefs.DATA_STRIDE_Z + var column_top := mini(chunk.heights[column], VoxelDefs.WORLD_HEIGHT - 1) + if float(column_top) - world_position.y < 4.0: + return {"active": false, "biome": BiomeCatalog.CAVE_BIOME_NONE, "tint": Color.WHITE, "depth": 0.0} + var ceiling_y := -1 + for y in range(position.y + 1, column_top + 1): + var overhead := get_block_world(Vector3i(position.x, y, position.z)) + if overhead != BlockRegistry.BLOCK_AIR \ + and not _blocks.has_flag(overhead, BlockRegistry.FLAG_CROSS) \ + and not _blocks.has_flag(overhead, BlockRegistry.FLAG_LEAVES): + ceiling_y = y + break + if ceiling_y < 0: + return {"active": false, "biome": BiomeCatalog.CAVE_BIOME_NONE, "tint": Color.WHITE, "depth": 0.0} + var biome := _generator.cave_biome_id_at(position.x, position.y, position.z) + if not BiomeCatalog.is_cave_biome(biome): + return {"active": false, "biome": biome, "tint": Color.WHITE, "depth": 0.0} + var surface_y := float(chunk.heights[column]) + var depth := clampf((surface_y - world_position.y) / 48.0, 0.0, 1.0) + return {"active": true, "biome": biome, "tint": BiomeCatalog.cave_ambience_color(biome), "depth": depth} + + func break_block(block_position: Vector3i) -> int: if block_position.y <= 0: return BlockRegistry.BLOCK_AIR @@ -820,17 +860,13 @@ func _water_place(position: Vector3i, block_id: int) -> void: func _touch_chunk(chunk_position: Vector2i, block_position: Vector3i) -> void: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 - _queue_rebuild(chunk_position) - var local_x := block_position.x - chunk_position.x * VoxelDefs.CHUNK_SIZE - var local_z := block_position.z - chunk_position.y * VoxelDefs.CHUNK_SIZE - if local_x == 0: - _queue_rebuild(chunk_position + Vector2i(-1, 0)) - elif local_x == VoxelDefs.CHUNK_SIZE - 1: - _queue_rebuild(chunk_position + Vector2i(1, 0)) - if local_z == 0: - _queue_rebuild(chunk_position + Vector2i(0, -1)) - elif local_z == VoxelDefs.CHUNK_SIZE - 1: - _queue_rebuild(chunk_position + Vector2i(0, 1)) + _queue_rebuild(chunk_position, true) + # A full light volume consumes all eight neighboring chunks. Sky and block + # light can travel MAX_LIGHT_LEVEL - 1 cells, so even a non-edge edit can + # alter baked light in an adjacent chunk; invalidate the complete 3x3 ring. + for direction in VoxelDefs.DIRS_8: + if _chunks.has(chunk_position + direction): + _queue_rebuild(chunk_position + direction, true) func get_spawn_position() -> Vector3: diff --git a/world/worldgen/README.md b/world/worldgen/README.md index 73d8c18..f5e2c23 100644 --- a/world/worldgen/README.md +++ b/world/worldgen/README.md @@ -21,7 +21,8 @@ catalogs/samplers used by worker jobs. A chunk then runs these ordered stages: secondary biome into coherent surface and vegetation patches without per-block biome dithering. 5. `VoxelPopulator.populate()` fills strata, carves caves, adds liquids and ore - veins, stamps global-cell decorations, then applies player edits last. + veins, cave biomes/dressing and geodes, stamps global-cell surface + decorations, then applies player edits last. 6. `VoxelPopulator.populate_lod()` handles distance chunks: it writes compact per-column top/sub/water arrays instead of a full voxel volume and skips caves, ores, and ground flora. Real tree crowns are baked in using in-field @@ -52,7 +53,8 @@ ranges live in `world_gen_config.gd`. - `regional_erosion`: broad rainfall/transport erosion strength. - `hydraulic_erosion`: deterministic 64x64 droplet tiles. This is deliberately off by default because cold generation is substantially slower. -- `cave_density`: worm, cavern, and mega-cave occurrence. +- `cave_density`: spaghetti thickness plus cross-link, chamber, cavern, and + mega-cave frequency. A thin canonical trunk remains at every nonzero value. - `tree_density`: multiplier for tree-class decorations only. - `decoration_density`: multiplier for all surface decoration. @@ -62,6 +64,52 @@ The underwater split constants (shelf/abyss depths, patch scales, biome temperature/depth gates) live at the top of `terrain_sampler.gd`. Keep block IDs below 256 and add placeable blocks through `BlockRegistry` first. +### Caves and geodes + +Cave geometry is a hybrid based on the systems used by modern Minecraft and +Luanti/Minetest. Two absolute-coordinate 3D ridge fields are intersected to +produce organic, seam-free "spaghetti" tunnels; a low-frequency cheese field +opens irregular chambers. Those fields are unioned with a global 48-block graph +in five depth bands. Every graph node owns a curved east-or-south trunk, making +each route mathematically unbounded instead of a short random walk; deterministic +cross-links, one vertical connector per column, node chambers, cell caverns, and +rare mega-caves create loops and changes of scale. Chunks enumerate graph owners +through a 64-block halo and clip stamps locally, so results do not depend on load +order. Surface entrances continue from their protected mouth to a canonical +graph node rather than ending blindly in stone. + +Cave biomes are a separate 3D layer in `BiomeCatalog.cave_biome_at()`, not +surface climate IDs. Broad global regions cover most underground territory: +lush caves dominate the damp middle depths, while deep dark and lush regions +share the lowest cavern band. `VoxelPopulator` only applies +their materials to stone exposed beside cave air: lush regions grow moss and +hanging cave growth, while deep-dark regions spread deepstone and faintly +emissive sculk. A sparse four-block lattice finds floors and ceilings for +dripstone, columns, and shallow pools without a full-volume decoration scan. +Surface entrances use the same deterministic anchor test as tree exclusion and +have an 18% candidate rate at default density (about 150 blocks mean spacing +before biome rejection), making natural access far less dependent on blind +digging. The HUD and environment ambience scan to the loaded column top, so tall +mega-caves show their cave-biome identity as well. + +Geodes use 72-block global cells plus an 11-block halo (roughly one candidate +per thirteen cells), so every touched chunk +clips the same sphere independently. Their shell, calcite lining, hollow center, +amethyst deposits, and crystal buds run after ores/liquids and before cave +dressing; player edits remain the final authoritative stage. Generated cave +textures live in `assets/placeholders/caves/` and are reproducible through +`tools/gen_underwater_textures.gd` (which owns both generated texture sets). + +`ChunkMesher` lights a 3x3 footprint because sky and block light have only 15 +levels and cannot contribute from beyond that pad. Missing neighbor tiles are +opaque light boundaries until loaded, while face culling still uses the normal +neighbor snapshot. All eight sampled chunks invalidate one another on commits +and edits. `tools/worldgen_cave_verify.gd` checks a sealed cavern, a remote cave +opening and finite falloff, colored crystal emission, exact volume bounds, cave +materials/vegetation, pools, dripstone, and hollow geode layers. It also follows +a canonical trunk for 128 macro cells and flood-fills a separately carved 5x5 +chunk fixture, requiring one component to span the region in X/Z and depth. + Large and small offshore islands are separate sparse peak fields layered onto effective continentalness at 720- and 135-block scales. They are coast-gated against the original mainland field, so they leave continental interiors and @@ -208,6 +256,9 @@ to review it; an already-running world retains its configured sampler and chunks regression guardrails, not a substitute for visual review. - `redot --headless --path . --script res://tools/worldgen_benchmark.gd` records full and LOD voxel-generation time for pinned chunks. +- `redot --headless --path . --script res://tools/worldgen_cave_verify.gd` + checks cave-biome classification and dressing, hollow layered geodes, crystal + emission, sealed cave darkness, opening falloff, and mesher light bounds. - `redot --headless --path . --script res://tools/worldgen_mesh_benchmark.gd` records full and LOD mesh CPU time for pinned chunks. - `redot --headless --path . --script res://tools/worldgen_tree_verify.gd` diff --git a/world/worldgen/biome_catalog.gd b/world/worldgen/biome_catalog.gd index abd363d..35abce6 100644 --- a/world/worldgen/biome_catalog.gd +++ b/world/worldgen/biome_catalog.gd @@ -24,6 +24,13 @@ const KELP_FOREST: int = 15 const SEAGRASS_MEADOW: int = 16 const CORAL_REEF: int = 17 const FROZEN_OCEAN: int = 18 +## Underground regions are appended to preserve every surface/ocean ID. They +## are classified in three dimensions and never participate in surface climate +## selection; VoxelPopulator uses them only on exposed cave surfaces. +const LUSH_CAVES: int = 19 +const DEEP_DARK: int = 20 +const CAVE_BIOME_NONE: int = -1 +const CAVE_REGION_SIZE: int = 96 const DECORATION_NONE: int = 0 const DECORATION_GRASSLAND: int = 1 @@ -77,6 +84,9 @@ const BLOCK_MUD: int = 18 const BLOCK_RED_SAND: int = 19 const BLOCK_TERRACOTTA: int = 25 const BLOCK_CORAL_SUBSTRATE: int = 52 +const BLOCK_MOSS: int = 62 +const BLOCK_DEEPSTONE: int = 64 +const BLOCK_SCULK: int = 65 var _names: PackedStringArray = PackedStringArray() var _temperature_centers: PackedFloat32Array = PackedFloat32Array() @@ -110,6 +120,8 @@ func _init() -> void: _add("seagrass_meadow", 0.58, 0.70, BLOCK_SAND, BLOCK_CLAY, BLOCK_SAND, 3, Color("#4f9c7a"), DECORATION_SEAGRASS, TYPE_NONE) _add("coral_reef", 0.74, 0.70, BLOCK_SAND, BLOCK_CLAY, BLOCK_SAND, 3, Color("#3fae9a"), DECORATION_REEF, TYPE_NONE) _add("frozen_sea", 0.08, 0.60, BLOCK_GRAVEL, BLOCK_STONE, BLOCK_GRAVEL, 2, Color("#9fc6d4"), DECORATION_FROZEN_SEA, TYPE_NONE) + _add("lush_caves", 0.52, 1.0, BLOCK_MOSS, BLOCK_STONE, BLOCK_STONE, 2, Color("#67a855"), DECORATION_NONE, TYPE_NONE) + _add("deep_dark", 0.18, 0.38, BLOCK_DEEPSTONE, BLOCK_SCULK, BLOCK_DEEPSTONE, 2, Color("#24525a"), DECORATION_NONE, TYPE_NONE) func biome_count() -> int: @@ -141,7 +153,7 @@ func is_ocean_biome(biome: int) -> bool: ## inheriting a guess from its temperature center. Static so the weather poll ## can classify a biome id without constructing a catalog. static func is_cold_biome(biome: int) -> bool: - match clampi(biome, 0, FROZEN_OCEAN): + match biome: SNOW, TAIGA, HIGHLANDS, FROZEN_OCEAN: return true return false @@ -150,7 +162,64 @@ static func is_cold_biome(biome: int) -> bool: ## Wetland biomes get low ground mist particles. Swamps are the only land ## biome with standing water and dense cover today. static func is_wetland_biome(biome: int) -> bool: - return clampi(biome, 0, FROZEN_OCEAN) == SWAMP + return biome == SWAMP + + +## Coherent 3D cave-region identity. Surface biomes remain a 2D climate layer; +## callers must additionally verify that the queried voxel is underground air. +## Broad global cells keep the result deterministic and worker-safe while the +## depth gates reserve lush caves for the damp middle band and deep dark for +## the lowest caverns. +static func cave_biome_at(seed: int, world_x: int, y: int, world_z: int) -> int: + if y < 5 or y > 78: + return CAVE_BIOME_NONE + var cell_x := floori(float(world_x) / float(CAVE_REGION_SIZE)) + var cell_z := floori(float(world_z) / float(CAVE_REGION_SIZE)) + var hash_value := _cave_hash(seed, cell_x, cell_z) + var roll := hash_value % 100 + if y <= 34: + if roll < 45: + return DEEP_DARK + if roll < 90: + return LUSH_CAVES + elif roll < 90: + return LUSH_CAVES + return CAVE_BIOME_NONE + + +static func is_cave_biome(biome: int) -> bool: + return biome == LUSH_CAVES or biome == DEEP_DARK + + +static func cave_surface_block(biome: int, selector: int = 0) -> int: + if biome == LUSH_CAVES: + return BLOCK_MOSS + if biome == DEEP_DARK: + return BLOCK_SCULK if selector % 5 == 0 else BLOCK_DEEPSTONE + return BLOCK_STONE + + +static func cave_ambience_color(biome: int) -> Color: + if biome == LUSH_CAVES: + return Color("#315c3e") + if biome == DEEP_DARK: + return Color("#102b35") + return Color("#242936") + + +static func cave_display_name(biome: int) -> String: + if biome == LUSH_CAVES: + return "LUSH CAVES" + if biome == DEEP_DARK: + return "DEEP DARK" + return "CAVES" + + +static func _cave_hash(seed: int, x: int, z: int) -> int: + var value: int = seed ^ (x * 73856093) ^ (z * 19349663) ^ 0x35A4D19 + value = ((value ^ (value >> 16)) * 0x45D9F3B) & 0x7fffffff + value = ((value ^ (value >> 16)) * 0x45D9F3B) & 0x7fffffff + return (value ^ (value >> 16)) & 0x7fffffff func temperature_center(biome: int) -> float: diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index 7c0f6f5..dde8358 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -24,13 +24,20 @@ const TREE_GROVE_CELL_SIZE: int = 48 const TREE_GROVE_SEARCH_RADIUS: int = 1 const GROUND_COVER_CELL_SIZE: int = 8 const GROUND_COVER_RADIUS: int = 4 -const CAVE_CELL_SIZE: int = 24 +const CAVE_NETWORK_CELL_SIZE: int = 48 +const CAVE_NETWORK_HALO: int = 64 +const CAVE_NETWORK_BANDS: int = 5 +const CAVE_NETWORK_BASE_Y: int = 14 +const CAVE_NETWORK_BAND_STEP: int = 18 const ORE_CELL_SIZE: int = 20 var config: WorldGenConfig var biomes: BiomeCatalog var decorations: DecorationCatalog var terrain_sampler: TerrainSampler +var _cave_spaghetti_a: FastNoiseLite +var _cave_spaghetti_b: FastNoiseLite +var _cave_cheese: FastNoiseLite func _init(config_value: WorldGenConfig, biomes_value: BiomeCatalog, sampler_value: TerrainSampler = null) -> void: @@ -38,6 +45,9 @@ func _init(config_value: WorldGenConfig, biomes_value: BiomeCatalog, sampler_val biomes = biomes_value if biomes_value != null else BiomeCatalogScript.new() terrain_sampler = sampler_value decorations = DecorationCatalogScript.new() + _cave_spaghetti_a = _make_cave_noise(1701, 0.018, 2) + _cave_spaghetti_b = _make_cave_noise(1877, 0.015, 2) + _cave_cheese = _make_cave_noise(1999, 0.009, 3) ## Returns {"data": PackedByteArray, "max_y": int}. Edits deliberately run @@ -51,12 +61,14 @@ func populate(chunk_pos: Vector2i, field: ChunkTerrainData, edits: Dictionary, f if full_detail: _decorate_floor_patches(data, field, origin_x, origin_z) if full_detail and config.world_type != WorldGenConfigScript.WORLD_TYPE_FLAT and config.cave_density > 0.0: - _carve_worm_caves(data, field, origin_x, origin_z) + _carve_noise_caves(data, field, origin_x, origin_z) + _carve_cave_network(data, field, origin_x, origin_z) _carve_cave_entrances(data, field, origin_x, origin_z) _carve_caverns(data, field, origin_x, origin_z) _fill_underground_liquids(data, field, origin_x, origin_z) _place_ore_veins(data, origin_x, origin_z) - _decorate_caves(data, origin_x, origin_z) + _place_geodes(data, field, origin_x, origin_z) + _decorate_caves(data, field, origin_x, origin_z) if full_detail and config.decoration_density > 0.0: max_y = _decorate(data, field, origin_x, origin_z, max_y) max_y = _decorate_underwater(data, field, origin_x, origin_z, max_y) @@ -257,32 +269,103 @@ func _badlands_stratum(world_x: int, y: int, world_z: int) -> int: return BlockRegistryScript.BLOCK_RED_SAND if band < 3 else BlockRegistryScript.BLOCK_TERRACOTTA -## A handful of connected, cell-anchored segments are substantially cheaper -## than sampling a hash/noise function for every underground voxel. -func _carve_worm_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: - var first_x: int = WorldGenHashScript.floor_div(origin_x - 24, CAVE_CELL_SIZE) - var last_x: int = WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE + 24, CAVE_CELL_SIZE) - var first_z: int = WorldGenHashScript.floor_div(origin_z - 24, CAVE_CELL_SIZE) - var last_z: int = WorldGenHashScript.floor_div(origin_z + VoxelDefsScript.CHUNK_SIZE + 24, CAVE_CELL_SIZE) +## Minecraft-style noise caves: the intersection of two continuous 3D ridges +## forms spaghetti tunnels that do not have feature endpoints or chunk seams. +## A lower-frequency "cheese" field opens occasional broad chambers. This is +## deliberately combined with the graph below: the field supplies organic +## local complexity while the graph guarantees routes that continue forever. +func _carve_noise_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: + var tunnel_width := clampf(0.055 + 0.035 * config.cave_density, 0.04, 0.16) + var chamber_threshold := clampf(0.62 - 0.07 * config.cave_density, 0.44, 0.62) + for local_z in VoxelDefsScript.CHUNK_SIZE: + var world_z := origin_z + local_z + for local_x in VoxelDefsScript.CHUNK_SIZE: + var field_index := ChunkTerrainDataScript.cell_index(local_x, local_z) + var surface_limit := _surface_height(field, field_index) - SURFACE_CLEARANCE - 1 + if field.river[field_index] >= 0.62: + surface_limit = mini(surface_limit, VoxelDefsScript.SEA_LEVEL - 3) + var last_y := mini(surface_limit, VoxelDefsScript.SEA_LEVEL + 52) + if last_y < 4: + continue + var world_x := origin_x + local_x + for y in range(4, last_y + 1): + var voxel_index := _index(local_x, y, local_z) + if not _is_carvable_stone(data[voxel_index]): + continue + var high_taper := clampf(float(VoxelDefsScript.SEA_LEVEL + 52 - y) / 24.0, 0.35, 1.0) + var ridge_a := absf(_cave_spaghetti_a.get_noise_3d(world_x, y, world_z)) + var ridge_b := absf(_cave_spaghetti_b.get_noise_3d(world_x, y, world_z)) + var spaghetti := maxf(ridge_a, ridge_b) < tunnel_width * high_taper + var depth_bias := clampf(float(y - 12) / 72.0, 0.0, 1.0) * 0.10 + var cheese := _cave_cheese.get_noise_3d(world_x, y, world_z) > chamber_threshold + depth_bias + if spaghetti or cheese: + data[voxel_index] = BlockRegistryScript.BLOCK_AIR + + +## A deterministic global graph supplies the long-distance structure missing +## from pure random walks. Every node owns one east/south edge, so following a +## trunk can never reach a dead end; optional cross-links, vertical connectors, +## curved midpoints, and node chambers keep the lattice from reading as a grid. +func _carve_cave_network(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: + var first_x := WorldGenHashScript.floor_div(origin_x - CAVE_NETWORK_HALO, CAVE_NETWORK_CELL_SIZE) + var last_x := WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + CAVE_NETWORK_HALO, CAVE_NETWORK_CELL_SIZE) + var first_z := WorldGenHashScript.floor_div(origin_z - CAVE_NETWORK_HALO, CAVE_NETWORK_CELL_SIZE) + var last_z := WorldGenHashScript.floor_div(origin_z + VoxelDefsScript.CHUNK_SIZE - 1 + CAVE_NETWORK_HALO, CAVE_NETWORK_CELL_SIZE) for cell_z in range(first_z, last_z + 1): for cell_x in range(first_x, last_x + 1): - for cell_y in range(0, 5): - var anchor_hash: int = WorldGenHashScript.hash_3d(config.seed + 701, cell_x, cell_y, cell_z) - var chance: float = float(anchor_hash % 1000) / 1000.0 - if chance >= minf(0.66, 0.20 * config.cave_density): - continue - var point := Vector3i( - cell_x * CAVE_CELL_SIZE + 2 + anchor_hash % (CAVE_CELL_SIZE - 4), - cell_y * CAVE_CELL_SIZE + 5 + (anchor_hash / 11) % 14, - cell_z * CAVE_CELL_SIZE + 2 + (anchor_hash / 97) % (CAVE_CELL_SIZE - 4) - ) - var segment_count: int = 2 + anchor_hash % 3 - for segment in range(segment_count): - var step_hash: int = WorldGenHashScript.hash_3d(config.seed + 719 + segment, cell_x, cell_y, cell_z) - var next := point + Vector3i((step_hash % 13) - 6, ((step_hash / 17) % 9) - 4, ((step_hash / 241) % 13) - 6) - next.y = clampi(next.y, 4, VoxelDefsScript.SEA_LEVEL + 44) - _stamp_tunnel(data, field, point, next, 2 + step_hash % 2) - point = next + for band in CAVE_NETWORK_BANDS: + var node := _cave_network_node(cell_x, band, cell_z) + var route_hash := WorldGenHashScript.hash_3d(config.seed + 719, cell_x, band, cell_z) + var primary_offset := Vector2i(1, 0) if (route_hash & 1) == 0 else Vector2i(0, 1) + _stamp_cave_network_edge(data, field, node, + _cave_network_node(cell_x + primary_offset.x, band, cell_z + primary_offset.y), route_hash) + if float(route_hash % 1000) / 1000.0 < minf(0.72, 0.24 * config.cave_density): + var branch_offset := Vector2i(0, 1) if primary_offset.x != 0 else Vector2i(1, 0) + _stamp_cave_network_edge(data, field, node, + _cave_network_node(cell_x + branch_offset.x, band, cell_z + branch_offset.y), route_hash + 43) + var connector_band := WorldGenHashScript.hash_2d(config.seed + 757, cell_x, cell_z) % (CAVE_NETWORK_BANDS - 1) + if band == connector_band: + _stamp_cave_network_edge(data, field, node, _cave_network_node(cell_x, band + 1, cell_z), route_hash + 89) + if float((route_hash / 97) % 1000) / 1000.0 < minf(0.30, 0.10 * config.cave_density): + _carve_ellipsoid(data, field, node, 6 + route_hash % 5, 3 + (route_hash / 7) % 4, 6 + (route_hash / 17) % 5) + + +func _cave_network_node(cell_x: int, band: int, cell_z: int) -> Vector3i: + var node_hash := WorldGenHashScript.hash_3d(config.seed + 701, cell_x, band, cell_z) + return Vector3i( + cell_x * CAVE_NETWORK_CELL_SIZE + 8 + node_hash % (CAVE_NETWORK_CELL_SIZE - 16), + clampi(CAVE_NETWORK_BASE_Y + band * CAVE_NETWORK_BAND_STEP + ((node_hash / 47) % 11) - 5, 5, VoxelDefsScript.SEA_LEVEL + 58), + cell_z * CAVE_NETWORK_CELL_SIZE + 8 + (node_hash / 131) % (CAVE_NETWORK_CELL_SIZE - 16)) + + +func _stamp_cave_network_edge(data: PackedByteArray, field: ChunkTerrainData, start: Vector3i, finish: Vector3i, route_hash: int) -> void: + var midpoint := Vector3i((start.x + finish.x) / 2, (start.y + finish.y) / 2, (start.z + finish.z) / 2) + var bend := ((route_hash / 13) % 19) - 9 + if absi(finish.x - start.x) >= absi(finish.z - start.z): + midpoint.z += bend + else: + midpoint.x += bend + midpoint.y = clampi(midpoint.y + ((route_hash / 251) % 11) - 5, 5, VoxelDefsScript.SEA_LEVEL + 58) + var base_radius := 1 if config.cave_density < 0.6 else (3 if config.cave_density > 1.75 else 2) + var radius := base_radius + route_hash % 2 + _stamp_tunnel(data, field, start, midpoint, radius) + _stamp_tunnel(data, field, midpoint, finish, base_radius + (route_hash / 17) % 2) + + +func _make_cave_noise(seed_salt: int, frequency: float, octaves: int) -> FastNoiseLite: + var noise := FastNoiseLite.new() + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.frequency = frequency + noise.fractal_type = FastNoiseLite.FRACTAL_FBM + noise.fractal_octaves = octaves + noise.fractal_lacunarity = 2.0 + noise.fractal_gain = 0.5 + noise.seed = WorldGenHashScript.hash_1d(config.seed, seed_salt) + return noise + + +func _is_carvable_stone(block_id: int) -> bool: + return block_id == BlockRegistryScript.BLOCK_STONE or block_id == BlockRegistryScript.BLOCK_COBBLESTONE func _stamp_tunnel(data: PackedByteArray, field: ChunkTerrainData, start: Vector3i, finish: Vector3i, radius: int) -> void: @@ -296,14 +379,14 @@ func _stamp_tunnel(data: PackedByteArray, field: ChunkTerrainData, start: Vector _carve_ellipsoid(data, field, point, radius, radius, radius) -## Sparse global-cell entrances descend diagonally into the worm band. Every -## neighboring chunk evaluates the same origin and clips the same tunnel, so an -## opening crossing a border never depends on generation order. +## Sparse global-cell entrances descend into a canonical graph node. The mouth +## alone may cut surface blocks; its continuation uses normal cave clearance. +## Every touched chunk reproduces the route, independent of generation order. func _carve_cave_entrances(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: if terrain_sampler == null: return const entrance_cell_size := 64 - const entrance_halo := 24 + const entrance_halo := CAVE_NETWORK_HALO var first_x := WorldGenHashScript.floor_div(origin_x - entrance_halo, entrance_cell_size) var last_x := WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + entrance_halo, entrance_cell_size) var first_z := WorldGenHashScript.floor_div(origin_z - entrance_halo, entrance_cell_size) @@ -311,7 +394,7 @@ func _carve_cave_entrances(data: PackedByteArray, field: ChunkTerrainData, origi for cell_z in range(first_z, last_z + 1): for cell_x in range(first_x, last_x + 1): var hash_value := WorldGenHashScript.hash_2d(config.seed + 773, cell_x, cell_z) - if float(hash_value % 1000) / 1000.0 >= minf(0.12, 0.04 * config.cave_density): + if float(hash_value % 1000) / 1000.0 >= minf(0.45, 0.18 * config.cave_density): continue var world_x := cell_x * entrance_cell_size + 8 + hash_value % 48 var world_z := cell_z * entrance_cell_size + 8 + (hash_value / 53) % 48 @@ -321,8 +404,13 @@ func _carve_cave_entrances(data: PackedByteArray, field: ChunkTerrainData, origi var direction_x := -1 if ((hash_value / 101) & 1) == 0 else 1 var direction_z := -1 if ((hash_value / 211) & 1) == 0 else 1 var start := Vector3i(world_x, ground.x + 1, world_z) - var finish := Vector3i(world_x + direction_x * 8, ground.x - 12, world_z + direction_z * 8) - _stamp_entrance_tunnel(data, field, start, finish) + var mouth_finish := Vector3i(world_x + direction_x * 8, ground.x - 12, world_z + direction_z * 8) + _stamp_entrance_tunnel(data, field, start, mouth_finish) + var network_cell_x := WorldGenHashScript.floor_div(mouth_finish.x, CAVE_NETWORK_CELL_SIZE) + var network_cell_z := WorldGenHashScript.floor_div(mouth_finish.z, CAVE_NETWORK_CELL_SIZE) + var target_band := clampi(WorldGenHashScript.floor_div(ground.x - 26 - CAVE_NETWORK_BASE_Y, CAVE_NETWORK_BAND_STEP), 0, CAVE_NETWORK_BANDS - 1) + var network_node := _cave_network_node(network_cell_x, target_band, network_cell_z) + _stamp_tunnel(data, field, mouth_finish, network_node, 2 + hash_value % 2) func _stamp_entrance_tunnel(data: PackedByteArray, field: ChunkTerrainData, start: Vector3i, finish: Vector3i) -> void: @@ -461,28 +549,191 @@ func _place_ore_veins(data: PackedByteArray, origin_x: int, origin_z: int) -> vo _stamp_ore_segment(data, origin_x, origin_z, start, finish, ore) -## Sparse deterministic cave details: damp floor patches plus stone teeth on -## cave ceilings/floors. Anchors use global cells, so formations cross chunk -## boundaries without depending on generation order. -func _decorate_caves(data: PackedByteArray, origin_x: int, origin_z: int) -> void: +## Sparse deterministic cave details. A global four-block lattice samples open +## chambers instead of scanning every underground voxel. The nearest floor and +## ceiling drive paired dripstone, columns, biome materials/vegetation, and +## shallow pools; all writes remain inside the current chunk-owned array. +func _decorate_caves(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: for local_z in range(2, VoxelDefsScript.CHUNK_SIZE, 4): for local_x in range(2, VoxelDefsScript.CHUNK_SIZE, 4): var world_x := origin_x + local_x var world_z := origin_z + local_z - for y in range(6, VoxelDefsScript.SEA_LEVEL + 32, 5): + var field_index := ChunkTerrainDataScript.cell_index(local_x, local_z) + # Cave dressing must remain below the protected surface band. Without + # this guard, an outdoor air sample can find the terrain (or ocean + # water) beneath it and mistake that one-sided boundary for a cavern, + # producing lattice rows of moss and dripstone across the landscape. + var underground_limit := _surface_height(field, field_index) - SURFACE_CLEARANCE - 1 + for y in range(6, VoxelDefsScript.SEA_LEVEL + 32, 4): + if y > underground_limit: + continue var air_index := _index(local_x, y, local_z) if data[air_index] != BlockRegistryScript.BLOCK_AIR: continue var hash_value := WorldGenHashScript.hash_3d(config.seed + 1061, world_x, y, world_z) - if data[_index(local_x, y - 1, local_z)] == BlockRegistryScript.BLOCK_STONE and hash_value % 9 == 0: - data[_index(local_x, y - 1, local_z)] = BlockRegistryScript.BLOCK_MYCELIUM if hash_value % 3 == 0 else BlockRegistryScript.BLOCK_MUD - if data[_index(local_x, y + 1, local_z)] == BlockRegistryScript.BLOCK_STONE and hash_value % 13 == 0: - var length := 1 + hash_value % 3 - for offset in range(length): - var target_y := y - offset - if target_y <= 2 or data[_index(local_x, target_y, local_z)] != BlockRegistryScript.BLOCK_AIR: - break - data[_index(local_x, target_y, local_z)] = BlockRegistryScript.BLOCK_TERRACOTTA + var floor_y := _cave_solid_y(data, local_x, y, local_z, -1, 8) + var ceiling_y := _cave_solid_y(data, local_x, y, local_z, 1, 10) + if floor_y < 1 and ceiling_y < 1: + continue + var cave_biome := BiomeCatalogScript.cave_biome_at(config.seed, world_x, y, world_z) + if floor_y >= 1 and _is_cave_stone(data[_index(local_x, floor_y, local_z)]): + if cave_biome != BiomeCatalogScript.CAVE_BIOME_NONE and hash_value % 3 != 0: + _paint_cave_floor_patch(data, local_x, floor_y, local_z, cave_biome, hash_value) + elif hash_value % 11 == 0: + data[_index(local_x, floor_y, local_z)] = BlockRegistryScript.BLOCK_MYCELIUM if hash_value % 2 == 0 else BlockRegistryScript.BLOCK_MUD + if cave_biome == BiomeCatalogScript.LUSH_CAVES: + _decorate_lush_cave(data, local_x, local_z, floor_y, ceiling_y, hash_value) + elif cave_biome == BiomeCatalogScript.DEEP_DARK: + _decorate_deep_dark(data, local_x, local_z, floor_y, ceiling_y, hash_value) + if (floor_y >= 1 or ceiling_y >= 1) and hash_value % 13 == 0: + _stamp_dripstone(data, local_x, local_z, floor_y, ceiling_y, hash_value) + if floor_y >= 4 and local_x >= 2 and local_x <= VoxelDefsScript.CHUNK_SIZE - 3 \ + and local_z >= 2 and local_z <= VoxelDefsScript.CHUNK_SIZE - 3 and hash_value % 41 == 0: + _stamp_small_pool(data, local_x, floor_y + 1, local_z, hash_value) + + +func _cave_solid_y(data: PackedByteArray, local_x: int, start_y: int, local_z: int, direction: int, reach: int) -> int: + for distance in range(1, reach + 1): + var y := start_y + distance * direction + if y <= 1 or y >= VoxelDefsScript.WORLD_HEIGHT - 1: + break + if data[_index(local_x, y, local_z)] != BlockRegistryScript.BLOCK_AIR: + return y + return -1 + + +func _is_cave_stone(block_id: int) -> bool: + return block_id in [BlockRegistryScript.BLOCK_STONE, BlockRegistryScript.BLOCK_DEEPSTONE, + BlockRegistryScript.BLOCK_MOSS, BlockRegistryScript.BLOCK_SCULK] + + +## Five-wide patches turn the four-block cave lattice into readable material +## regions rather than isolated checkerboard pixels. Each neighboring column +## independently finds a floor within one block of the anchor, preserving +## slopes and never filling cave air. +func _paint_cave_floor_patch(data: PackedByteArray, center_x: int, floor_y: int, center_z: int, cave_biome: int, hash_value: int) -> void: + for z in range(maxi(center_z - 2, 0), mini(center_z + 2, VoxelDefsScript.CHUNK_SIZE - 1) + 1): + for x in range(maxi(center_x - 2, 0), mini(center_x + 2, VoxelDefsScript.CHUNK_SIZE - 1) + 1): + for y_offset in [0, -1, 1]: + var target_y: int = floor_y + int(y_offset) + if target_y <= 1 or target_y + 1 >= VoxelDefsScript.WORLD_HEIGHT: + continue + var index := _index(x, target_y, z) + if not _is_cave_stone(data[index]) or data[_index(x, target_y + 1, z)] != BlockRegistryScript.BLOCK_AIR: + continue + var selector := WorldGenHashScript.hash_3d(config.seed + 1073 + hash_value, x, target_y, z) + data[index] = BiomeCatalogScript.cave_surface_block(cave_biome, selector) + break + + +func _decorate_lush_cave(data: PackedByteArray, local_x: int, local_z: int, floor_y: int, ceiling_y: int, hash_value: int) -> void: + if floor_y >= 1: + var plant_y := floor_y + 1 + if plant_y < VoxelDefsScript.WORLD_HEIGHT and data[_index(local_x, plant_y, local_z)] == BlockRegistryScript.BLOCK_AIR and hash_value % 3 == 0: + data[_index(local_x, plant_y, local_z)] = BlockRegistryScript.BLOCK_CAVE_MOSS + if ceiling_y >= 1 and hash_value % 5 == 0: + var hanging_y := ceiling_y - 1 + if data[_index(local_x, hanging_y, local_z)] == BlockRegistryScript.BLOCK_AIR: + data[_index(local_x, hanging_y, local_z)] = BlockRegistryScript.BLOCK_CAVE_MOSS + + +func _decorate_deep_dark(data: PackedByteArray, local_x: int, local_z: int, floor_y: int, ceiling_y: int, hash_value: int) -> void: + for surface_y in [floor_y, ceiling_y]: + if surface_y >= 1 and _is_cave_stone(data[_index(local_x, surface_y, local_z)]) and hash_value % 4 != 0: + data[_index(local_x, surface_y, local_z)] = BlockRegistryScript.BLOCK_SCULK if hash_value % 2 == 0 else BlockRegistryScript.BLOCK_DEEPSTONE + + +func _stamp_dripstone(data: PackedByteArray, local_x: int, local_z: int, floor_y: int, ceiling_y: int, hash_value: int) -> void: + var gap := ceiling_y - floor_y - 1 if floor_y >= 1 and ceiling_y >= 1 else 10 + var ceiling_length := mini(1 + hash_value % 4, gap) if ceiling_y >= 1 else 0 + var floor_length := mini(1 + (hash_value / 7) % 3, maxi(gap - ceiling_length, 0)) if floor_y >= 1 else 0 + if floor_y >= 1 and ceiling_y >= 1 and gap <= 7 and hash_value % 3 == 0: + ceiling_length = gap + floor_length = 0 + for offset in ceiling_length: + var y := ceiling_y - 1 - offset + if data[_index(local_x, y, local_z)] != BlockRegistryScript.BLOCK_AIR: + break + data[_index(local_x, y, local_z)] = BlockRegistryScript.BLOCK_DRIPSTONE + for offset in floor_length: + var y := floor_y + 1 + offset + if data[_index(local_x, y, local_z)] != BlockRegistryScript.BLOCK_AIR: + break + data[_index(local_x, y, local_z)] = BlockRegistryScript.BLOCK_DRIPSTONE + + +func _stamp_small_pool(data: PackedByteArray, center_x: int, water_y: int, center_z: int, hash_value: int) -> void: + var radius := 1 + hash_value % 2 + for z in range(center_z - radius, center_z + radius + 1): + for x in range(center_x - radius, center_x + radius + 1): + if (x - center_x) * (x - center_x) + (z - center_z) * (z - center_z) > radius * radius: + continue + if data[_index(x, water_y, z)] == BlockRegistryScript.BLOCK_AIR \ + and data[_index(x, water_y - 1, z)] != BlockRegistryScript.BLOCK_AIR: + data[_index(x, water_y, z)] = BlockRegistryScript.BLOCK_WATER + + +## Rare global-cell geodes are evaluated with a radius halo, so the same sphere +## is clipped consistently by every touched chunk. A dark shell encloses a pale +## calcite lining, amethyst deposits, a hollow center, and emissive crystal buds. +func _place_geodes(data: PackedByteArray, field: ChunkTerrainData, origin_x: int, origin_z: int) -> void: + const cell_size := 72 + const halo := 11 + var first_x := WorldGenHashScript.floor_div(origin_x - halo, cell_size) + var last_x := WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE - 1 + halo, cell_size) + var first_z := WorldGenHashScript.floor_div(origin_z - halo, cell_size) + var last_z := WorldGenHashScript.floor_div(origin_z + VoxelDefsScript.CHUNK_SIZE - 1 + halo, cell_size) + for cell_z in range(first_z, last_z + 1): + for cell_x in range(first_x, last_x + 1): + var hash_value := WorldGenHashScript.hash_2d(config.seed + 1327, cell_x, cell_z) + if hash_value % 13 != 0: + continue + var center := Vector3i(cell_x * cell_size + 12 + hash_value % 48, + 10 + (hash_value / 29) % 31, cell_z * cell_size + 12 + (hash_value / 71) % 48) + _stamp_geode(data, field, center, 6 + hash_value % 4, hash_value) + + +func _stamp_geode(data: PackedByteArray, field: ChunkTerrainData, center: Vector3i, radius: int, seed_hash: int) -> void: + var origin_x := field.chunk_x * VoxelDefsScript.CHUNK_SIZE + var origin_z := field.chunk_z * VoxelDefsScript.CHUNK_SIZE + for world_z in range(maxi(center.z - radius, origin_z), mini(center.z + radius, origin_z + VoxelDefsScript.CHUNK_SIZE - 1) + 1): + var local_z := world_z - origin_z + for world_x in range(maxi(center.x - radius, origin_x), mini(center.x + radius, origin_x + VoxelDefsScript.CHUNK_SIZE - 1) + 1): + var local_x := world_x - origin_x + var field_index := ChunkTerrainDataScript.cell_index(local_x, local_z) + var max_geode_y := _surface_height(field, field_index) - SURFACE_CLEARANCE - 2 + for y in range(maxi(2, center.y - radius), mini(center.y + radius, max_geode_y) + 1): + var offset := Vector3(float(world_x - center.x), float(y - center.y), float(world_z - center.z)) + var distance := offset.length() / float(radius) + if distance > 1.0: + continue + var index := _index(local_x, y, local_z) + var existing := data[index] + if distance >= 0.82: + if existing == BlockRegistryScript.BLOCK_STONE: + data[index] = BlockRegistryScript.BLOCK_GEODE_SHELL + elif distance >= 0.66: + if existing != BlockRegistryScript.BLOCK_BEDROCK: + var crystal_hash := WorldGenHashScript.hash_3d(config.seed + 1361 + seed_hash, world_x, y, world_z) + data[index] = BlockRegistryScript.BLOCK_AMETHYST if crystal_hash % 5 == 0 else BlockRegistryScript.BLOCK_CALCITE + elif existing != BlockRegistryScript.BLOCK_BEDROCK: + data[index] = BlockRegistryScript.BLOCK_AIR + # Buds occupy hollow cells adjacent to the crystalline lining. + for world_z in range(maxi(center.z - radius + 2, origin_z), mini(center.z + radius - 2, origin_z + VoxelDefsScript.CHUNK_SIZE - 1) + 1): + var local_z := world_z - origin_z + for world_x in range(maxi(center.x - radius + 2, origin_x), mini(center.x + radius - 2, origin_x + VoxelDefsScript.CHUNK_SIZE - 1) + 1): + var local_x := world_x - origin_x + for y in range(maxi(3, center.y - radius + 2), mini(VoxelDefsScript.WORLD_HEIGHT - 2, center.y + radius - 2) + 1): + var index := _index(local_x, y, local_z) + if data[index] != BlockRegistryScript.BLOCK_AIR or WorldGenHashScript.hash_3d(config.seed + 1399, world_x, y, world_z) % 17 != 0: + continue + for direction in [Vector3i.UP, Vector3i.DOWN, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.FORWARD, Vector3i.BACK]: + var neighbor: Vector3i = Vector3i(local_x, y, local_z) + Vector3i(direction) + if neighbor.x < 0 or neighbor.x >= VoxelDefsScript.CHUNK_SIZE or neighbor.z < 0 or neighbor.z >= VoxelDefsScript.CHUNK_SIZE: + continue + if data[_index(neighbor.x, neighbor.y, neighbor.z)] == BlockRegistryScript.BLOCK_AMETHYST: + data[index] = BlockRegistryScript.BLOCK_CRYSTAL_BUD + break func _ore_for_anchor(hash_value: int, y: int) -> int: @@ -785,7 +1036,7 @@ func _tree_root_is_near_cave_entrance(world_x: int, world_z: int) -> bool: for cell_z in range(first_z, last_z + 1): for cell_x in range(first_x, last_x + 1): var hash_value: int = WorldGenHashScript.hash_2d(config.seed + 773, cell_x, cell_z) - if float(hash_value % 1000) / 1000.0 >= minf(0.12, 0.04 * config.cave_density): + if float(hash_value % 1000) / 1000.0 >= minf(0.45, 0.18 * config.cave_density): continue var start_x: int = cell_x * entrance_cell_size + 8 + hash_value % 48 var start_z: int = cell_z * entrance_cell_size + 8 + (hash_value / 53) % 48 diff --git a/world/worldgen/world_gen_config.gd b/world/worldgen/world_gen_config.gd index f8474bd..dda75a1 100644 --- a/world/worldgen/world_gen_config.gd +++ b/world/worldgen/world_gen_config.gd @@ -3,7 +3,7 @@ class_name WorldGenConfig extends RefCounted -const CURRENT_VERSION: int = 6 +const CURRENT_VERSION: int = 8 const WORLD_TYPE_NORMAL: int = 0 const WORLD_TYPE_FLAT: int = 1 From f394143d4aa5b6f6e7c75d1ca4a0e4abd5a494c8 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Mon, 14 Sep 2026 22:49:34 +0100 Subject: [PATCH 2/2] Address cave review feedback --- assets/placeholders/caves/README.md | 6 +++ tools/explosives_verify.gd | 23 +++++++++++ tools/worldgen_cave_verify.gd | 3 +- world/voxel_world.gd | 60 ++++++++++++++--------------- world/worldgen/voxel_populator.gd | 7 +++- 5 files changed, 66 insertions(+), 33 deletions(-) create mode 100644 assets/placeholders/caves/README.md diff --git a/assets/placeholders/caves/README.md b/assets/placeholders/caves/README.md new file mode 100644 index 0000000..3b6447c --- /dev/null +++ b/assets/placeholders/caves/README.md @@ -0,0 +1,6 @@ +# Cave placeholder textures + +These 64 px cave-biome, geode, and crystal textures were generated specifically +for RedotCraft. They do not contain third-party artwork. Regenerate them with: + +`redot --headless --path . --script res://tools/gen_underwater_textures.gd` diff --git a/tools/explosives_verify.gd b/tools/explosives_verify.gd index 0f089ef..b417de9 100644 --- a/tools/explosives_verify.gd +++ b/tools/explosives_verify.gd @@ -12,6 +12,10 @@ func _ready() -> void: var chunk := VoxelWorld.Chunk.new() chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) world._chunks[Vector2i.ZERO] = chunk + for direction in VoxelDefs.DIRS_8: + var neighbor := VoxelWorld.Chunk.new() + neighbor.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + world._chunks[direction] = neighbor for y in range(1, 21): for z in VoxelDefs.CHUNK_SIZE: for x in VoxelDefs.CHUNK_SIZE: @@ -34,6 +38,25 @@ func _ready() -> void: "blast was not recorded as a persistent edit") or failed failed = _expect(world._chunk_edit_version.get(Vector2i.ZERO, 0) == 1, "blast invalidated its changed chunk more than once") or failed + for direction in VoxelDefs.DIRS_8: + failed = _expect(world._gen_queued.has(direction), + "blast did not invalidate sampled light neighbor %s" % direction) or failed + + # Multiple fluid writes in one cellular tick must bump/requeue each touched + # chunk only once; otherwise the full 3x3 light ring multiplies water churn. + world._gen_queue.clear() + world._gen_queued.clear() + world._dirty.clear() + var water_source := Vector3i(2, 18, 2) + var water_target := water_source + Vector3i(1, 0, 0) + _set_block(chunk.data, water_source, BlockRegistry.BLOCK_WATER) + _set_block(chunk.data, water_target, BlockRegistry.BLOCK_AIR) + world._queue_water(water_target) + world._water_tick() + failed = _expect(world._blocks.is_water_id(world.get_block_world(water_target)), + "water tick did not place flowing water") or failed + failed = _expect(world._chunk_edit_version.get(Vector2i.ZERO, 0) == 2, + "water tick invalidated one changed chunk more than once") or failed _set_block(chunk.data, center, BlockRegistry.BLOCK_NUKE) var nuke_result := world.trigger_explosive(center) diff --git a/tools/worldgen_cave_verify.gd b/tools/worldgen_cave_verify.gd index 2f407a1..456cac8 100644 --- a/tools/worldgen_cave_verify.gd +++ b/tools/worldgen_cave_verify.gd @@ -71,8 +71,7 @@ func _verify_endless_cave_network() -> void: var visited_cells := {} for step in 128: visited_cells[cell] = true - var route_hash := WorldGenHash.hash_3d(int(TEST_CONFIG.seed) + 719, cell.x, 2, cell.y) - cell += Vector2i(1, 0) if (route_hash & 1) == 0 else Vector2i(0, 1) + cell += populator._cave_primary_offset(cell.x, 2, cell.y) _expect(visited_cells.size() == 128 and cell.x + cell.y == 128, "canonical cave trunk terminated or looped instead of continuing globally") diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 991d2f8..5e7cac1 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -723,13 +723,13 @@ func carve_sphere(center: Vector3i, radius: int) -> int: water_shell.append(position) if column_changed: changed_chunks[chunk_position] = true - rebuild_chunks[chunk_position] = true - _collect_edge_rebuilds(rebuild_chunks, chunk_position, Vector3i(x, center.y, z)) for chunk_position in changed_chunks: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + rebuild_chunks[chunk_position] = true + _add_loaded_light_ring(rebuild_chunks, chunk_position) for chunk_position in rebuild_chunks: - # Edge neighbors also need fresh face culling. Preserve this request if - # their current job already captured the pre-explosion neighbor snapshot. + # Every sampled neighbor needs fresh light and face data. Preserve this + # request if its current job captured the pre-explosion snapshot. _queue_rebuild(chunk_position, true) for position in water_shell: for offset in WATER_NEIGHBOR_OFFSETS: @@ -739,21 +739,6 @@ func carve_sphere(center: Vector3i, radius: int) -> int: for position in interior_water: _seed_water(position) return removed - - -func _collect_edge_rebuilds(rebuilds: Dictionary, chunk_position: Vector2i, block_position: Vector3i) -> void: - var local_x := block_position.x - chunk_position.x * VoxelDefs.CHUNK_SIZE - var local_z := block_position.z - chunk_position.y * VoxelDefs.CHUNK_SIZE - if local_x == 0: - rebuilds[chunk_position + Vector2i(-1, 0)] = true - elif local_x == VoxelDefs.CHUNK_SIZE - 1: - rebuilds[chunk_position + Vector2i(1, 0)] = true - if local_z == 0: - rebuilds[chunk_position + Vector2i(0, -1)] = true - elif local_z == VoxelDefs.CHUNK_SIZE - 1: - rebuilds[chunk_position + Vector2i(0, 1)] = true - - func _record_edit(block_position: Vector3i, block_id: int) -> void: _record_edit_in_chunk(block_position, block_id, _chunk_for_block(block_position)) @@ -792,6 +777,7 @@ func _queue_water(position: Vector3i) -> void: ## changes, so oceans and lakes stay static until disturbed. func _water_tick() -> void: var budget := WATER_CELLS_PER_TICK + var changed_chunks := {} while budget > 0 and _water_head < _water_queue.size(): var position: Vector3i = _water_queue[_water_head] _water_head += 1 @@ -799,7 +785,15 @@ func _water_tick() -> void: _water_queued.erase(position) if _loaded_chunk_for(position) == null: continue - _update_water_cell(position) + _update_water_cell(position, changed_chunks) + if not changed_chunks.is_empty(): + var rebuild_chunks := {} + for chunk_position in changed_chunks: + _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + rebuild_chunks[chunk_position] = true + _add_loaded_light_ring(rebuild_chunks, chunk_position) + for chunk_position in rebuild_chunks: + _queue_rebuild(chunk_position, true) if _water_head == _water_queue.size(): _water_queue.clear() _water_head = 0 @@ -808,7 +802,7 @@ func _water_tick() -> void: _water_head = 0 -func _update_water_cell(position: Vector3i) -> void: +func _update_water_cell(position: Vector3i, changed_chunks: Dictionary) -> void: var id := get_block_world(position) if id != BlockRegistry.BLOCK_AIR and not _blocks.is_water_id(id): return @@ -825,13 +819,13 @@ func _update_water_cell(position: Vector3i) -> void: if is_source: feed = 8 if feed != current_level: - _water_place(position, _blocks.water_id_for_level(feed)) + _water_place(position, _blocks.water_id_for_level(feed), changed_chunks) if feed <= 0: return var below := get_block_world(position + Vector3i(0, -1, 0)) if below == BlockRegistry.BLOCK_AIR: if position.y > 1: - _water_place(position + Vector3i(0, -1, 0), _blocks.water_id_for_level(7)) + _water_place(position + Vector3i(0, -1, 0), _blocks.water_id_for_level(7), changed_chunks) return if feed <= 1: return @@ -839,12 +833,12 @@ func _update_water_cell(position: Vector3i) -> void: var target := position + Vector3i(direction.x, 0, direction.y) var target_id := get_block_world(target) if target_id == BlockRegistry.BLOCK_AIR: - _water_place(target, _blocks.water_id_for_level(feed - 1)) + _water_place(target, _blocks.water_id_for_level(feed - 1), changed_chunks) elif _blocks.is_water_id(target_id) and target_id != BlockRegistry.BLOCK_WATER and _blocks.water_level(target_id) < feed - 1: - _water_place(target, _blocks.water_id_for_level(feed - 1)) + _water_place(target, _blocks.water_id_for_level(feed - 1), changed_chunks) -func _water_place(position: Vector3i, block_id: int) -> void: +func _water_place(position: Vector3i, block_id: int, changed_chunks: Dictionary) -> void: var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return @@ -853,20 +847,26 @@ func _water_place(position: Vector3i, block_id: int) -> void: return chunk.data[index] = block_id _record_edit(position, block_id) - _touch_chunk(_chunk_for_block(position), position) + changed_chunks[_chunk_for_block(position)] = true for offset in WATER_NEIGHBOR_OFFSETS: _queue_water(position + offset) -func _touch_chunk(chunk_position: Vector2i, block_position: Vector3i) -> void: +func _touch_chunk(chunk_position: Vector2i, _block_position: Vector3i) -> void: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 - _queue_rebuild(chunk_position, true) + var rebuild_chunks := {chunk_position: true} # A full light volume consumes all eight neighboring chunks. Sky and block # light can travel MAX_LIGHT_LEVEL - 1 cells, so even a non-edge edit can # alter baked light in an adjacent chunk; invalidate the complete 3x3 ring. + _add_loaded_light_ring(rebuild_chunks, chunk_position) + for rebuild_position in rebuild_chunks: + _queue_rebuild(rebuild_position, true) + + +func _add_loaded_light_ring(rebuilds: Dictionary, chunk_position: Vector2i) -> void: for direction in VoxelDefs.DIRS_8: if _chunks.has(chunk_position + direction): - _queue_rebuild(chunk_position + direction, true) + rebuilds[chunk_position + direction] = true func get_spawn_position() -> Vector3: diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index dde8358..0676d88 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -316,7 +316,7 @@ func _carve_cave_network(data: PackedByteArray, field: ChunkTerrainData, origin_ for band in CAVE_NETWORK_BANDS: var node := _cave_network_node(cell_x, band, cell_z) var route_hash := WorldGenHashScript.hash_3d(config.seed + 719, cell_x, band, cell_z) - var primary_offset := Vector2i(1, 0) if (route_hash & 1) == 0 else Vector2i(0, 1) + var primary_offset := _cave_primary_offset(cell_x, band, cell_z) _stamp_cave_network_edge(data, field, node, _cave_network_node(cell_x + primary_offset.x, band, cell_z + primary_offset.y), route_hash) if float(route_hash % 1000) / 1000.0 < minf(0.72, 0.24 * config.cave_density): @@ -338,6 +338,11 @@ func _cave_network_node(cell_x: int, band: int, cell_z: int) -> Vector3i: cell_z * CAVE_NETWORK_CELL_SIZE + 8 + (node_hash / 131) % (CAVE_NETWORK_CELL_SIZE - 16)) +func _cave_primary_offset(cell_x: int, band: int, cell_z: int) -> Vector2i: + var route_hash := WorldGenHashScript.hash_3d(config.seed + 719, cell_x, band, cell_z) + return Vector2i(1, 0) if (route_hash & 1) == 0 else Vector2i(0, 1) + + func _stamp_cave_network_edge(data: PackedByteArray, field: ChunkTerrainData, start: Vector3i, finish: Vector3i, route_hash: int) -> void: var midpoint := Vector3i((start.x + finish.x) / 2, (start.y + finish.y) / 2, (start.z + finish.z) / 2) var bend := ((route_hash / 13) % 19) - 9