Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 27740e2e8f geometry.rs: GbuffersInputs only in its tests; lockfiles: the pipeline crate takes naga
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:06 +01:00
Horizon agentandClaude Opus 5.5 a7d211a1fc Translated programs' types move to horizon-render-pipeline
A translated program as the renderer builds pipelines from it (Program, ComputeProgram,
Interface, Uniform, Sampler, Image, Target, Error, DRAW_UNIFORMS) and the vertex inputs the
renderer offers it (GbuffersInputs, GeometryInputs, AlphaTest, WATER_MASK, WATER_PATCHES) move
unchanged from horizon-shaders' translator, which re-exports them. The renderer's library code
takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:42:43 +01:00
Ludovic Marie 7ae28b9d3e Merge branch 'split/render-pipeline-stages' into split/render-pipeline-textures 2026-11-09 16:35:58 +01:00
Ludovic Marie 0b7d3f8105 Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:35:02 +01:00
Horizon agentandClaude Opus 5.5 2f124cc0d2 pack_post.rs: import BUFFERS instead of re-declaring it, so the file doesn't grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:34:13 +01:00
Horizon agentandClaude Opus 5.5 950c368a3c Custom textures and render stages move to horizon-render-pipeline
The textures a pipeline's programs read (CustomTexture, Texture, Texels, TextureSource, Dim,
EVERY_STAGE) and the render stages they are told they draw in (RENDER_STAGES, render_stage)
move unchanged from horizon-shaders, which still reads them from a pack and re-exports them.
The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:07 +01:00
Horizon agentandClaude Opus 5.5 a6093def90 launcher and registry lockfiles: horizon-shader-api depends on horizon-render-pipeline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:43 +01:00
Horizon agentandClaude Opus 5.5 80aa1a5b06 Cargo.lock: horizon-shader-api takes bytemuck through the pipeline crate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:20:23 +01:00
Ludovic Marie 8da02c530e Merge branch 'split/render-pipeline-config' into split/render-pipeline-stages 2026-11-09 16:20:23 +01:00
Horizon agentandClaude Opus 5.5 7081c411dd Cargo.lock: horizon-render-pipeline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:52 +01:00
Horizon agentandClaude Opus 5.5 a23f327048 WGSL stage interface moves to horizon-render-pipeline
The stages Horizon's own packs fill, their preludes, bindings and entry points (stages.rs),
the frame uniform the renderer uploads (inputs.rs) and the API lines (Api) are the renderer's
interface, so they move unchanged into the engine's pipeline crate. horizon-shader-api keeps
reading, composing and checking packs against them and re-exports them under the same paths
(launcher and registry unchanged). The renderer takes them from the engine crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:58:12 +01:00
Horizon agentandClaude Opus 5.5 5052d73cd4 horizon-render-pipeline: the pipeline's buffers and shadow map leave horizon-shaders
A new engine crate describes what an external shader pipeline gives the renderer. Its first
part is the colortex buffers and the shadow map (Format, Kind, Buffer, Shadow, Config, the
buffer names): moved unchanged from horizon-shaders' config.rs, which keeps reading them from a
pack's stage files (ReadConfig) and re-exports them. The renderer takes them from the new
crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:45 +01:00
Ludovic Marie ad67d515ad Merge pull request #13916 from horizon-rust/decisions/java-bridge-docs
🤖 Agent: docs only; nothing of the bridge is on main, the offer prompt and benchmark protocol are planned in #13914.
2026-11-09 15:41:40 +01:00
Ludovic Marie 08c8433e3c Merge pull request #13912 from horizon-rust/decisions/unreviewed-translations
🤖 Agent: the review record keeps only text a person checked, so every new machine string starts unreviewed without anyone marking it; seeded from git blame, erring towards unreviewed.
2026-11-09 15:41:36 +01:00
Horizon agentandClaude Opus 5.5 06fba30acc Docs: the Java bridge is a separate optional download, benchmarked against Java before promotion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:26:54 +01:00
Horizon agentandClaude Opus 5.5 b147d89df6 Translations: an unreviewed mark on machine-written strings
The project decided on 2026-11-09 that machine translations carry an
"unreviewed" mark that a person who speaks the language clears.
tools/lang_review/{game,launcher}/<code>.json records the text a person
wrote or checked; every other entry, or one changed since, is unreviewed.
lang_coverage.py counts them, lists them (--unreviewed) and records
checked entries (--mark-reviewed). docs/translations.md has the reviewer
workflow through #translations on Discord. The first record keeps the
1,917 entries git blame traces to single-language human contributors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:21:31 +01:00
Ludovic Marie 1be2c30f53 Merge pull request #13011 from horizon-rust/layers/plan
🤖 Agent: docs only; NikitaMGrimm's hud_layout.rs correction folded in (engine; the widget framework is hud_kit), M3/M4a marked merged, M5/M6 started 2026-11-09 by project decision.
2026-11-09 15:14:42 +01:00
Horizon agent d00cc3aa14 Merge remote-tracking branch 'origin/main' into layers/plan 2026-11-09 15:12:15 +01:00
Horizon agentandClaude Opus 5.5 a9e7940df2 docs/layers.md: M3 and M4's first part merged, M5 and M6 started
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:59 +01:00
Horizon agentandClaude Opus 5.5 9f4ef776d4 docs/layers.md: hud_layout.rs is engine; the HUD widget framework is hud_kit
NikitaMGrimm's correction on #13011.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:22 +01:00
Ludovic Marie 21a750533e Merge pull request #13866 from NikitaMGrimm/windows-pr-ready
🤖 Agent: test-only and tooling changes. The one Rust change opens the folder with FILE_FLAG_BACKUP_SEMANTICS only on Windows, so other platforms run the same test as before
2026-11-09 14:54:56 +01:00
Ludovic Marie 1341217fdf Merge pull request #13847 from NikitaMGrimm/tooling/file-size-skip-test-modules
🤖 Agent: when in doubt the module counts: a test module is skipped only when the brace count and rustfmt's layout agree on its end, so real code can't hide in one
2026-11-09 14:54:52 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 42a15b0561 Python tools and tests, and a mods test: the same results on Windows
- check_file_size and stamp_native_hooks read git's output and their files as UTF-8, not
  in the code page (cp1252 can't decode 0x9d, in UTF-8 such as "”").
- test_optional_release_assets runs the bash on PATH, as test_pr_ready does: a bare "bash"
  is WSL's on Windows, which gets none of the steps' env.
- The live-switch verdict test opens the mod folder so Windows can set its time.

Fixes #13858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:57:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3ff3720bc5 File size check: a test module whose braces close before its last line counts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:34:26 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e8474ed6eb File size check: inline test modules don't count
A normal build doesn't compile `#[cfg(test)] mod name { ... }` modules, so they no longer count toward the
3000-line limit, on the base or now. The failure message names both ways out: a sibling `<file>_tests.rs`, or a
pure-move PR first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:27:09 +01:00
Ludovic Marie 3b691ea10b Merge pull request #13833 from horizon-rust/perf/glass-mods-cull
🤖 Agent: virtualises the launcher's mods list (2000 mods: 28 → 60 fps scrolling)
2026-11-09 13:02:59 +01:00
Ludovic Marie 64353830de Merge pull request #13698 from horizon-rust/perf/tracker-slab
🤖 Agent: ServerEntity slab found in last tick's order instead of states.entry per entity (40 % of the tracker): lan_track_entities 6.26→3.75, 7.07→4.74, 6.29→4.68 ms/tick (3 interleaved rounds on playhorizon, 122 players, live SMP entity mix); ~-1.6 ms projected live. Scratch differential test vs main's tracker (300 random levels x 150 ticks x 5 clients): packets byte-identical. Union with #13720: 284 harnesses pass incl. dedicated_server, server_entities, late_entity_pair; 22 fail identically on main.
2026-11-09 12:59:13 +01:00
Ludovic Marie 2dd69539cb Merge pull request #13720 from horizon-rust/perf/idle-pass-order
🤖 Agent: idle-pass notes found in last pass order instead of a hash probe per frozen entity, players sorted by x, last type's range kept: lan_entity_states 8.67→5.25, 8.98→5.30, 8.91→4.96 ms/tick (3 interleaved rounds on playhorizon, 122 players, live SMP entity mix with ~21k frozen items); ~-2.6 ms projected live. Union with #13698: 284/306 selected harnesses pass, the 22 others fail identically on main ba0fdb381.
2026-11-09 12:59:08 +01:00
Horizon agentandClaude Sonnet 5.5 f9673db9a5 Launcher: the glass Mods results and Home's Worlds list build only the rows near the view
The glass Mods page built a row for every result it had (they add up behind
More), and Home's Worlds tab one for every world: with 2000 rows a scroll
frame cost about 36 ms. Both now go through cull::Cull, as the instance's mod
list, the releases and the classic Mods page already do: rows near the view
are built, the rest are one spacer each. The lists stay in the page's scroll
area, so the sticky project card, hover and click handling are unchanged. A
world being worked on or with its menu open stays built.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 12:41:03 +01:00
Ludovic Marie 0c60c83bcd Merge pull request #13709 from vnsmoreira/fix/falling-block-fall-float
🤖 Agent: I merged main; the falling block fall float case matches 26/26 on this branch alone, and only failed in a union where #13712's case unloaded chunk 250 two columns away (#13825, queued first, moves that case).
2026-11-09 12:26:37 +01:00
Ludovic Marie 7659d540f4 Merge pull request #13646 from MSedgeMC/fix/vex-owner-nbt
🤖 Agent: mob_ai passed (2868 s) on union19b with this branch and main; the vex's summoner survives the chunk save without changing its charge goal's draws.
2026-11-09 12:26:33 +01:00
Ludovic Marie 0492680f66 Merge pull request #13714 from NikitaMGrimm/parity/ravager-speed-save
🤖 Agent: your main merge drops the immobile ravager from made_at_load as Ravager.readAdditionalSaveData reads only the three counters (checked with rea_vanilla); ravager saved movement speed matches 32/32 and mob_ai (2868 s), mob_attributes and attribute pass on union19b with the same resolution.
2026-11-09 12:26:29 +01:00
Ludovic Marie 8c438cc3c7 Merge pull request #13708 from rdbtCVS/perf/hx-explosion-steps
🤖 Agent: reusing a step's take within the same block is exact: vanilla adds the block on the first step whose power stays above 0, and a step with no power left ends the ray, so a later step there can only re-add it; explosion_items passes and the command_exec explosion cases match on this branch alone.
2026-11-09 12:23:38 +01:00
Ludovic Marie c15ab62594 Merge pull request #13825 from horizon-rust/fix/corpus-projectile-fall-chunk
🤖 Agent: tooling only: moves #13712's case off chunk 250 so its unload no longer keeps vanilla's chunks 248/252 from entity ticking; command_exec on main fails explosion infested blocks 11/16, on this branch it matches (only the known zombie villager flake).
2026-11-09 12:23:34 +01:00
Horizon-agent cfcf8489a6 Merge main into #13709
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 12:20:16 +01:00
Horizon agentandClaude Opus 5.5 2183fc4a18 command_exec: run the projectile fall distance case in its own area
Its forceload remove at chunk 250 left vanilla's chunks 248 and 252
(two columns away) short of entity ticking for a few ticks while 250
unloaded, so the explosion infested blocks case (and #13709's falling
block case) saw no entity tick on the Java side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:06:03 +01:00
Ludovic Marie 77cee50f49 Merge pull request #13712 from vnsmoreira/fix/projectile-fall-distance
🤖 Agent: I merged main again after #13645 landed (the merger stopped on the conflict); the projectile fall distance cases match vanilla 15/15 and fall_damage passes.
2026-11-09 11:58:22 +01:00
Horizon-agent 5b8dc6d632 Merge main into #13712
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 11:47:25 +01:00
Ludovic Marie dc4e681d37 Merge pull request #13645 from hunixcode/fix/item-escape-own-random
🤖 Agent: I merged main after #13558: ItemEntity::new starts the random at seed 0 like this PR's other constructions, and Game::drop and a server's item set the own random that drew their UUID and bobOffs; the escape tests and the item harnesses (max stack, health load, numeric uuid, lava cauldron, held drop) pass on union19b.
2026-11-09 11:38:52 +01:00
Ludovic Marie 28b8b6e232 Merge pull request #13719 from MSedgeMC/test/lodestone-armor
🤖 Agent: the armour slots now get the same lodestone check as the inventory, as LivingEntity.tick ticks EntityEquipment; lodestone_default (with its new armour case) passes on union19b.
2026-11-09 11:38:48 +01:00
Ludovic Marie 6b4e3f59f8 Merge pull request #13700 from rdbtCVS/perf/hx-display-cache
🤖 Agent: I keyed the kept display data by level: the server thread tracks each dimension's others in turn, and one shared cache made each pass drop the other level's displays and rebuild them every tick; text_display, display_save, server_entities and lan pass on union19b.
2026-11-09 11:38:44 +01:00
Horizon agentandClaude Opus 5.5 3fa7bcdd69 Merge main into #13645: the own random rides on ItemEntity::new
#13558 builds items through ItemEntity::new; it starts the random at seed 0
as this PR's other constructions do, and the two spawn paths (Game::drop
and a server's item) set the own random that drew their UUID and bobOffs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:01:32 +01:00
Ludovic Marie 599ed14267 Merge pull request #13713 from vnsmoreira/packs/polytone-environment-fog
🤖 Agent: I merged the fixed #13704 in; with the default environment the underwater brightening is water_vision_over(600) × 1, the same ramp as before, so the vanilla look is unchanged.
2026-11-09 11:00:57 +01:00
Ludovic Marie 3303f4f1cc Merge pull request #13704 from vnsmoreira/packs/polytone-effect-colors
🤖 Agent: I merged main and pointed #13703's banner::dye_rgb at the renamed pack_palette (the land check stopped on pack_dyes); a pack's effect colour also reaches the hub's entity_effect particles, as an integrated Java server sharing MobEffect would.
2026-11-09 11:00:53 +01:00
Ludovic Marie bb7652f721 Merge pull request #13778 from horizon-rust/fix/mods-tests-isolation
🤖 Agent: isolates the remaining MODS-setting tests (same helper as #13733)
2026-11-09 10:39:43 +01:00
Horizon-agentandClaude Opus 5.5 f9e704effb Merge #13704 (main merged in) into #13713
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:30 +01:00
Horizon-agentandClaude Opus 5.5 ac0e2cb6be Banner layers read the renamed pack_palette
#13703 added banner::dye_rgb on pack_dyes, which this PR renames.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:29 +01:00
Horizon-agentandClaude Opus 5.5 54034799d2 Merge main into #13704
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:38:29 +01:00
Ludovic Marie 8a1e413e1f Merge pull request #13656 from vnsmoreira/fix/explosion-infested-silverfish
🤖 Agent: I merged main (the corpus keeps both sides' cases); explosion_items passed in union16, the explosion command_exec cases match, union18 explosion tests pass.
2026-11-09 10:29:53 +01:00
Ludovic Marie ce5571cac2 Merge pull request #13558 from vnsmoreira/fix/item-fall-distance
🤖 Agent: I merged main again; the merge keeps main's mod item_no_physics beside the new item_fall module, and the item fall distance cases matched vanilla in command_exec (union16: only the known zombie PersistenceRequired flake differs).
2026-11-09 10:29:48 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 55ec392c64 rustfmt: made_at_load's speed condition fits one line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:14:06 +01:00
Horizon-agentandClaude Opus 5.5 edef540e08 Merge main into #13700
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:10:27 +01:00
Horizon-agent 6187e0745d Merge main into #13712
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:10:14 +01:00
Horizon-agent 8f616098e8 Merge main into #13709
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:09:58 +01:00
Horizon-agent fb05f85337 Merge main into #13656
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:04:32 +01:00
Horizon-agent 1e3b7a1ce8 Merge main into #13558
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 10:04:25 +01:00
Horizon agentandClaude Sonnet 5.5 2f2c59c53b Run every MODS-setting test in its own process
mod_api::MODS is set once per process, so the tests that install trait
mods in the same test binary failed at random depending on which ran
first. Move #13733's in_own_process helper to mod_api and use it in the
remaining eight game tests and the mod_api test that set MODS.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 09:50:28 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 934d6a185f Merge origin/main into parity/ravager-speed-save
#13660 moved the attribute instances a load makes into finalize::made_at_load and
kept #12680's ravager parts there (the immobile ravager's speed instance, and the
saved speed base overwritten from the model). Main's structure stays; the ravager
parts go again, since the ravager's speed is now an attribute base that its
aiStep sets and the save writes like any other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:48:34 +01:00
Horizon-agent 8b3e3b9177 Merge main into #13656
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:41:36 +01:00
Horizon-agentandClaude Opus 5.5 4f0c8a4b37 Merge main into #13558
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:35:17 +01:00
Horizon-agent 4c96fd0ca9 Merge the author's rebase of #13558 (same change) into the main-merged branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:35:17 +01:00
Horizon agentandClaude Opus 5.5 0f901e30a1 Keep tracked displays per level
The server thread tracks each dimension in turn (levels.rs swaps others and dim), and the
cleanup compared one shared cache with the level in place: with players in two dimensions
each pass dropped the other level's displays and they were built again every tick. The
cache is now keyed by the level in place, so the cleanup only sees its own displays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:03:04 +01:00
Horizon agent 0fa1ee3a3c Merge remote-tracking branch 'origin/main' into x-13700 2026-11-09 09:01:31 +01:00
Horizon agent 2b42fac0cc Merge remote-tracking branch 'origin/main' into x-13712
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 09:00:28 +01:00
Horizon agent f9964c0cdf Merge remote-tracking branch 'origin/main' into x-13709
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 08:59:46 +01:00
Horizon-agent 0cf703b72b Merge main into #13558: keep both corpus cases
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 08:53:24 +01:00
MSedgeMC 65f5c93f41 fix: tick compasses worn in a player's armour slots
LodestoneTracker.tick runs from ItemStack.inventoryTick, which
LivingEntity.tick calls for every EntityEquipment slot as well as the
inventory. Horizon only ticked the inventory and offhand, so a compass
worn in an armour slot kept its target after the lodestone was broken.
Tick the four armour slots too, and extend the saved-default comparison
with an armour family and --no-armor.
2026-11-09 07:35:18 +00:00
Horizon-agentandClaude Opus 5.5 f36db58664 Restore mod item_no_physics that the main merge dropped
#13573 added the module; the merge into #13558 kept only the PR's mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:33:45 +01:00
Vinícius MoreiraandClaude Opus 5.5 61876b21de A dropped item keeps, saves and loads its fall distance
ItemEntity had no fall distance: it saved fall_distance: 0d and dropped a
loaded value. Entity.move now resets it on a fast move past a
fall-resetting block or water, checkFallDamage adds each step down
(narrowed to a float) outside water and ends it on the ground, water and
makeStuckInBlock reset it, lava halves it, and Entity.save / load keep it.

Fixes #13203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:33:02 -03:00
Vinícius MoreiraandClaude Opus 5.5 a7c2266c95 A projectile keeps the fall_distance it is given
Entity.load reads fall_distance for every entity and saveWithoutId
writes it; a projectile moves by setPos, never through Entity.move, so in
the air it keeps the loaded value. Horizon's Projectile had no fall
distance: fireballs, wither skulls and thrown projectiles saved 0.0d.

The projectile's Entity field helpers move to entity_nbt/projectile_fields.rs,
keeping entity_nbt.rs from growing.

Fixes #13702

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:36 -03:00
Vinícius MoreiraandClaude Opus 5.5 36c40227dc A falling block adds each step of its fall narrowed to a float
Entity.checkFallDamage adds fallDistance -= (float)ya for a move down
outside water, and a falling block never runs baseTick, so every step
counts. Horizon added the double difference of the two positions, which
differs from vanilla in the low bits from the first tick.

Fixes #13701

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:11 -03:00
Vinícius MoreiraandClaude Opus 5.5 cfc164308f A blast lets an infested block's silverfish out
Block.onExplosionHit runs spawnAfterBreak before the drops, and
InfestedBlock.spawnAfterBreak spawns its silverfish while block_drops is
on and the tool does not prevent it. Horizon spawned it only for a block
a player mines. Both paths now share spawn_infestation.

Part of #11330

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:30:08 -03:00
Horizon-agentandClaude Opus 5.5 2499cd929a Merge main into #13645
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:29:36 +01:00
Horizon-agentandClaude Opus 5.5 4b11e24b12 Merge main into #13558: keep both corpus cases (#13574 landed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:29:21 +01:00
Horizon-agentandClaude Opus 5.5 a57a829572 Merge main into #13645: the escape speed keeps the item's own random at #13573's new noPhysics spot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:28:22 +01:00
Horizon agentandClaude Opus 5.5 f3dc477663 Merge main into #13558: keep both sides' stations tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 08:26:35 +01:00
Vinícius MoreiraandClaude Opus 5.5 706b936a77 Polytone: environment fog settings
environment.powder_snow_fog, blindness_fog_distance, darkness_fog_distance,
boss_fog_distance, water_fog_brightening and water_fog_brightening_time replace the fog
values vanilla hardcodes, read once with the packs. Measured on the real mod (powder snow,
blindness, darkness, water); star_size and star_count change nothing there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:25:45 -03:00
Nikita Maximilian Grimm 3908880998 Merge remote-tracking branch 'origin/main' into parity/ravager-speed-save 2026-11-09 08:18:23 +01:00
Vinícius MoreiraandClaude Opus 5.5 baa73603e9 Polytone: effect, particle and redstone colours
effect.<id> recolours potions, tipped arrows and effect particles (the server's, as
Polytone changes the effect for the whole game in single player); particle.<id> is a
particle modifier that sets only the colour; redstone.<power> recolours the wire and its
dust. Measured on the real mod; effect.water and effect.empty change nothing there.
The dye table becomes pack_palette, which also holds the redstone colours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:54:45 -03:00
Horizon agentandClaude Opus 5.5 f2543c9c55 perf(lan): idle pass keeps the last type's tracking range
tracking::settings (a binary search over the type names) was a quarter of
the idle pass once its lookups were gone; the entities of a type come in
runs, so the last type's scaled range is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:53:08 +01:00
Horizon agentandClaude Opus 5.5 b607e0e0fd test(lan): late pairing tests read the slab by id
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:33:57 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a6cd0a67a7 Keep entity_nbt.rs from growing: one loop for the two ravager cases
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:11:35 +01:00
MSedgeMC ff59d29516 refactor: keep the owner comment on one line
mobs.rs is over the 3000-line limit and may not grow (docs/layers.md#file-size).
2026-11-09 06:00:27 +00:00
MSedgeMC 9ba2f28221 refactor: keep the vex's save code in vex.rs
`entity_nbt.rs` is over the 3000-line limit and may not grow (docs/layers.md
#file-size), so the vex save arm and its read side move into `mobs/vex.rs`
beside the tests and `owner`. `entity_nbt` keeps the one-line calls.
2026-11-09 05:54:28 +00:00
MSedgeMC e5817a0217 port_map: a vex's owner is saved and read
`tools/port_map/game2-003.tsv` had no answer for `Vex.addAdditionalSaveData` or
`Vex.readAdditionalSaveData`. Both now name
`entity_nbt::mob_to_nbt` / `mob_from_nbt`: `owner` as the owner's UUID, written
only for a vex that has one, with `bound_pos` and `life_ticks` beside it.
2026-11-09 05:46:44 +00:00
MSedgeMC 1a451f670f test: a vex's owner through the chunk save
`mob_from_nbt` and `mob_to_nbt` are the pair `Game::load_mob` and
`Game::mob_tree_nbt` go through, and `mobs::vex::owner` is what
`VexCopyOwnerTargetGoal` asks for the owner. A summoned vex (owner id 7, UUID
[3,1,4,1]) saves `owner: IntArray([3,1,4,1])`; the reloaded vex keeps the UUID
and has no id; a stub `WhoLookup` that answers the UUID with `Who::Mob(42)` gives
that owner (so an evoker that loaded first is found), while an empty level, a
UUID naming the player and a world with no lookups give none. A save naming no
owner writes no `owner` key even when the compound it came in with named one.

Without the save pair the key is missing (left: `None`, right:
`Some(IntArray([3, 1, 4, 1]))`) and the reloaded vex keeps no reference
(left: `None`, right: `Some([9, 9, 9, 9])` for the compound's own).
2026-11-09 05:46:44 +00:00
MSedgeMC d56cbb35f2 fix: keep a vex's owner across its chunk save
`Vex.addAdditionalSaveData` stores the reference to the mob that summoned it,
and `readAdditionalSaveData` reads it back:

    EntityReference.store(this.owner, output, "owner");   // UUIDUtil.CODEC, nothing when null
    ...
    this.owner = EntityReference.read(input, "owner");    // null when absent

`Vex.setOwner` takes a `Mob`, and `getOwner` resolves the reference through the
level every time it is asked (`EntityReference.getEntity`), so a vex that
reloaded before its evoker still copies that evoker's target once it is there.

Horizon models the owner as the owner's mob id (`extra.counter2`, which the
evoker sets when it summons the vex) and saved neither the id nor a UUID:
`VexCopyOwnerTargetGoal` had nothing to read after a reload, so a summoned vex
stopped copying its evoker's target and stood there once its chunk had been
reloaded.

The save now writes `owner` as the owner's UUID - the level's answer for an id
still in it, and the reference the vex was loaded with when the owner is gone -
and the load keeps that UUID in `extra.owner`, because an id would name
whatever mob holds it after the reload. `mobs::vex::owner` is `Vex.getOwner`:
the id while the evoker is in the level, the UUID resolved through
`MobWorld::who_by_uuid` (`WhoLookup`, #10365's seam that `Game::who_by_uuid`
backs) otherwise. A UUID naming the player is no owner, as `setOwner(Mob)`
takes a mob.
2026-11-09 05:46:44 +00:00
hunixandClaude Opus 5.5 a8e0fab5aa Items: escape a solid block at the item's own random speed
Entity.moveTowardsClosestSpace draws the push speed from the entity's own
random (`this.random.nextFloat() * 0.2F + 0.1F`), the one an item's
constructor drew its UUID, bobOffs, yaw and motion from. Horizon drew it
from the level's random, so the item left the block at another speed and
the level's stream was one draw ahead of vanilla's from then on.

ItemEntity keeps its own random now: a spawned item keeps the one its
constructor drew from, a loaded one the one that drew its UUID, bobOffs and
yaw, and /data keeps the entity's. Two small test modules move to their own
files (entities/experience_tests.rs, entity_nbt/spell_ticks_nbt_tests.rs),
unchanged, so entities.rs and entity_nbt.rs shrink instead of growing.

Fixes #13043

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:26 -10:00
Horizon agentandClaude Opus 5.5 8331f8cad8 perf(lan): tracked entities in a slab found in last tick's order
Entities::tick looked every tracked entity up by id in a hash map of
ServerEntity on every tick: with ~27k entities (most of them items and
mobs frozen past the simulation distance) nearly every probe missed the
cache, ~2.3 ms of a 105 ms tick at 122 players. The ServerEntitys now sit
in a slab; each tick notes the order it met them in, and the next tick
finds each entity at or just past where it found the one before. Entities
that joined or come out of order are looked up by id, as before. The send
pass takes each entity's slot from that order instead of a second lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:27:59 +01:00
Horizon agentandClaude Opus 5.5 48d52eba1b perf(lan): idle-entity notes in pass order, players sorted by x
The tracker's idle pass (game/lan_idle.rs) looked each idle mob and item up
in a per-level hash map and tested it against every player of the level, on
every tick: about 5 ms of a 105 ms tick at 122 players with ~21k frozen items.
The game meets its entities in the same order each tick, so the notes are
kept in that order and found at or just past the last one found; one found
nowhere near is described in full, which is always right. The players are
sorted by x so an entity tests only the ones within its range along x.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:25:37 +01:00
Nikita Maximilian Grimm 273883af4c Merge remote-tracking branch 'origin/main' into parity/ravager-speed-save 2026-11-09 06:15:59 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 6d05e015d2 A ravager keeps its saved movement speed and saves none before its first tick
Ravager.aiStep sets the movement_speed base (0.0 while biting, stunned or
roaring, then a lerp back toward 0.3), so the base is what AttributeMap.pack
saves and AttributeMap.apply restores. The ravager now writes that base, and
the special cases from #12680 are gone: the base_value short-circuit that made
a loaded base fall back to 0.3, the instance saved before the first tick, and
the 0.0 speed set at load.

Fixes #13621.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 06:15:57 +01:00
rdbtCVSandClaude Opus 5.5 5119526d69 Tracked displays: keep each display's built data while its compound stays
The synched data of the text, block and item displays a server keeps
unsimulated was built from their compound every tick: text components,
block states and items parsed again for every hologram and menu. It is now
kept per display and used while the display's kind, compound and block
registry are unchanged; an item display's stack is still asked of the hub
each time, as a build does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:04:19 +00:00
rdbtCVSandClaude Opus 5.5 d78b319c35 Explosions: a ray's step in the block its last step was in reuses that lookup
A blast's 1,352 rays step 0.3 blocks at a time, about three steps a block, and
each step looked its block, fluid, rails and resistance up again and put the
block in the set again. A step in the same block as the last now takes what
that one took (nothing changes while rays cast), and the block is already in
the set if it is to be.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:48:47 +00:00
Vinícius MoreiraandClaude Opus 5.5 c59ebc38c4 A dropped item keeps, saves and loads its fall distance
ItemEntity had no fall distance: it saved fall_distance: 0d and dropped a
loaded value. Entity.move now resets it on a fast move past a
fall-resetting block or water, checkFallDamage adds each step down
(narrowed to a float) outside water and ends it on the ground, water and
makeStuckInBlock reset it, lava halves it, and Entity.save / load keep it.

Fixes #13203

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:29:14 -03:00
Horizon agentandClaude Opus 5.5 73b0a91c13 docs/layers.md: the rule's wording, replays split, the licence note
- duckybertduck's placement rule in their words (tooling excepted; the
  rest can be built on what's below), plus: a feature often splits, the
  part others build on is platform, the finished feature Horizon.
- Replays: recording and playback (.mcpr) are platform; the Studio
  (camera paths, timeline) is Horizon or a native mod.
- Licence: if this repository goes GPL, the linked Horizon layer must be
  GPL-compatible (no closed native code); WASM mods could stay closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:10:39 +01:00
Horizon agentandClaude Opus 5.5 e547357ae7 docs/layers.md: controllers, splitscreen, Replay Studio, UI pages; the placement rule
Merges origin/main (keeps the rewritten page over #12993's edit of the
first version, which it already covers) and, from #contributors:
- duckybertduck's placement rule opens "Where a new feature goes".
- The compositor is engine (part of how the renderer presents a frame,
  idle on vanilla's one-view path), as Martipops and duckybertduck argued.
- Controllers, splitscreen, Replay Studio, the HUD widget framework and a
  UI page API are platform, on engine hooks named in a new section
  (actions and held state today; move vector, look delta and menu focus
  as an input source; screens). PvP presets and widgets stay Horizon.
- Plan states updated (#12983, #12990, #13015, #12993, #13002, #13058
  merged; #13060, #13094), M6 lists the input-source and page work.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:08:24 +01:00
Horizon agentandClaude Opus 5.5 b7b5cda0ad docs/layers.md: linked Horizon crates, portable crates, HDR in M6
From NikitaMGrimm's review on #13012:
- The Horizon layer's in-game parts are crates linked into the official
  game build in CI through platform hooks, never compiled by players. The
  official executable is a thin crate in the Horizon repository; this
  repository keeps a plain one.
- The portable crates (core, world, protocol, items, command, client-core,
  assets) take no desktop library; ports beyond the desktop build on them.
  #12997's text folded in; #13002 adds the check. Step P: gameplay into
  them over time.
- HDR's part of M6 is planned with NikitaMGrimm after HDR step 5.
- Credits name NikitaMGrimm (DuckyBertDuck on Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:45:36 +01:00
Horizon agent 53776cf792 Merge remote-tracking branch 'origin/main' into layers/plan 2026-11-09 01:45:30 +01:00
Horizon agentandClaude Opus 5.5 7da0bdf46d docs/layers.md: the file-size check is #13015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:58:02 +01:00
Horizon agentandClaude Opus 5.5 991a9b5451 docs/layers.md: the target (Horizon layer in its own repo) and the step plan
Ludovic, 2026-11-09: this repository keeps the engine and the platform,
a complete vanilla-like game; the Horizon layer (its extras, cosmetics,
shop, telemetry, sponsors, the launcher) moves to its own repository
after the split and ships with every official launcher download. So
Horizon uses only the platform's public API. Adds the seam, the
official download paths, the stage's asks (file-size rule, one address
list, sponsors in Horizon, a platform exposing what vanilla uses,
extras as native mods), the 47 exceptions and the M1-M6 plan with its
review gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:54:03 +01:00
135 changed files with 6883 additions and 2611 deletions

No files matched your search

+6 -2
View File
@@ -25,8 +25,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine uses engine; platform uses engine and platform; Horizon (its extras,
cosmetics, shop, telemetry, launcher) uses platform and Horizon only, never the engine: it moves to its own repository
later. `tools/check_layers.py` checks it. New Horizon code goes behind `horizon-extras`; built-in addresses go in
`horizon-addresses`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
@@ -39,6 +41,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
+15 -4
View File
@@ -45,9 +45,13 @@ and compares their outputs.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Three layers: engine, platform, Horizon.**
- Vanilla code never depends on the platform (mods, shader packs, HDR, the settings system) or on Horizon
(its extras, cosmetics, the shop, official servers, telemetry).
- The platform never depends on Horizon.
- Horizon uses only the platform, never the engine: it will live in its own repository.
[docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -93,7 +97,8 @@ names its sources: the sections of that specification, the documentation pages a
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
default. The game may offer to turn it on when it finds a Java mod; the player decides. It is promoted out of beta
only after MSPT and RAM benchmarks against the Java client with the same mods ([#13914](https://github.com/horizon-rust/horizon/issues/13914)).
## Before asking for review
@@ -113,6 +118,12 @@ including examples. The dependency scope includes optional, platform, build and
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
### Translations
Most of Horizon's translations were written by machine and stay marked unreviewed until someone who speaks the
language checks them. To report a wrong string or review your language, see [docs/translations.md](docs/translations.md)
and #translations on Horizon's Discord. Never mark machine-written text as reviewed.
## Claim before you start
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
Generated
+11 -1
View File
@@ -1593,6 +1593,7 @@ dependencies = [
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1852,6 +1853,14 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
]
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1900,7 +1909,7 @@ dependencies = [
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"bytemuck",
"horizon-render-pipeline",
"naga",
"png",
"serde",
@@ -1916,6 +1925,7 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+1
View File
@@ -39,6 +39,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
+24 -4
View File
@@ -1,7 +1,10 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
use std::{
collections::HashMap,
sync::{OnceLock, RwLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
@@ -18,6 +21,22 @@ pub struct EffectDef {
pub category: Category,
pub instant: bool,
}
/// The colours resource packs give effects (Polytone's `effect.<id>`), by the table's id.
static PACK_COLORS: RwLock<Option<HashMap<String, u32>>> = RwLock::new(None);
/// Takes the packs' effect colours (none: vanilla's).
pub fn set_pack_colors(colors: Vec<(String, u32)>) {
let colors = (!colors.is_empty()).then(|| colors.into_iter().collect());
*PACK_COLORS.write().unwrap_or_else(|e| e.into_inner()) = colors;
}
impl EffectDef {
/// `MobEffect.getColor` as the packs show it: potions, tipped arrows and effect particles.
pub fn shown_color(&self) -> u32 {
let pack = PACK_COLORS.read().unwrap_or_else(|e| e.into_inner());
pack.as_ref()
.and_then(|colors| colors.get(self.id))
.map_or(self.color, |c| c & 0xFF_FFFF)
}
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
@@ -309,9 +328,10 @@ pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
for (e, amp) in effects {
let Some(e) = effect(e) else { continue };
let w = amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
let color = e.shown_color();
r += w * (color >> 16 & 255);
g += w * (color >> 8 & 255);
b += w * (color & 255);
total += w;
}
match (
+1
View File
@@ -24,6 +24,7 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
@@ -575,14 +575,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Kind {
"banner" => {
let segment: f32 = prop("rotation").and_then(|r| r.parse().ok()).unwrap_or(0.0);
return Kind::Banner {
color: crate::pack_dyes::diffuse(dye),
color: crate::pack_palette::diffuse(dye),
wall: false,
angle: segment * 22.5,
};
}
"wall_banner" => {
return Kind::Banner {
color: crate::pack_dyes::diffuse(dye),
color: crate::pack_palette::diffuse(dye),
wall: true,
angle: y_rot(facing()),
};
+1 -1
View File
@@ -14,7 +14,7 @@ use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
register_mod_effects,
register_mod_effects, set_pack_colors,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
+68 -7
View File
@@ -110,10 +110,15 @@ impl FogState {
}
/// `LocalPlayer.getWaterVision`.
pub fn water_vision(&self, eye_in_water: bool) -> f32 {
self.water_vision_over(eye_in_water, 600.0)
}
/// The water vision a pack's `water_fog_brightening_time` gives: vanilla's ramp over that
/// many ticks, of the 600 the game counts (more never reaches full).
pub fn water_vision_over(&self, eye_in_water: bool, ticks: f32) -> f32 {
if !eye_in_water {
return 0.0;
}
let t = self.water_vision_time as f32;
let t = self.water_vision_time as f32 * 600.0 / ticks;
if t >= 600.0 {
return 1.0;
}
@@ -226,6 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -241,7 +248,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
// Base colour of the applicable environment.
let mut color = match input.fog_type {
FogType::Lava => rgb(0x99_1A00),
FogType::PowderSnow => rgb(0x9F_BACC),
FogType::PowderSnow => rgb(input.environment.powder_snow_fog.unwrap_or(0x9F_BACC)),
FogType::Water => input.water_fog_color,
FogType::Atmospheric => {
let mut fog = sky.fog_color;
@@ -299,8 +306,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water {
let env = &input.environment;
let brighten = if eye_in_water && input.full_water_vision {
water_vision
} else if eye_in_water {
state.water_vision_over(true, env.water_fog_brightening_time) * env.water_fog_brightening
} else {
input.night_vision
};
@@ -342,10 +352,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
let end = env.blindness_fog_distance;
let distance = if infinite {
5.0
end
} else {
rd + (5.0 - rd) * (duration as f32 / 20.0).min(1.0)
rd + (end - rd) * (duration as f32 / 20.0).min(1.0)
};
fog.environmental_start = distance * 0.25;
fog.environmental_end = distance;
@@ -355,7 +366,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
_ if input.darkness.is_some() => {
// DarknessFogEnvironment.setupFog: closing in to 15 blocks.
let blend = input.darkness.unwrap_or(0.0);
let distance = rd + (15.0 - rd) * blend;
let distance = rd + (env.darkness_fog_distance - rd) * blend;
fog.environmental_start = distance * 0.75;
fog.environmental_end = distance;
fog.sky_end = distance;
@@ -380,7 +391,7 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = ((input.cloud_range_chunks * 16) as f32).min(sky.cloud_fog_end);
if input.boss_world_fog {
fog.environmental_start = fog.environmental_start.min(10.0);
fog.environmental_end = fog.environmental_end.min(96.0);
fog.environmental_end = fog.environmental_end.min(env.boss_fog_distance);
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
@@ -483,6 +494,7 @@ mod tests {
fire_resistance: false,
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
}
}
#[test]
@@ -560,6 +572,55 @@ mod tests {
assert!(fog.environmental_start >= 1e9 && fog.render_distance_start >= 1e9);
}
#[test]
fn a_packs_environment_moves_effect_fog_and_its_colours() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = horizon_polytone::colors::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
..Default::default()
};
let mut input = inputs(&sky);
input.blindness = Some((-1, true));
input.environment = environment;
assert_eq!(
setup_fog(&tables, &FogState::default(), &input).environmental_end,
40.0
);
input.blindness = None;
input.fog_type = FogType::PowderSnow;
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [1.0, 0.0, 0.0, 1.0]);
// Under water for the full 600 ticks: vanilla washes the fog out to a full channel; a
// brightening of 0 keeps the colour given.
let mut state = FogState::default();
for _ in 0..600 {
state.tick(true, false);
}
input.fog_type = FogType::Water;
input.water_fog_color = [0.1, 0.2, 0.4];
let washed = setup_fog(&tables, &state, &inputs_with(&input, Default::default())).color;
assert_eq!(washed[2], 1.0);
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: horizon_polytone::colors::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
..*input
}
}
#[test]
fn fog_off_keeps_fluid_and_effect_fog() {
let sky = Sky {
fog_start: 10.0,
+10 -10
View File
@@ -7661,7 +7661,7 @@ impl Renderer {
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
.is_some_and(|p| p.0.manifest.api == horizon_render_pipeline::Api::V0_2);
if (linear || self.output.frame.format == LINEAR_FRAME)
&& linear != (self.output.frame.format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
@@ -7714,7 +7714,7 @@ impl Renderer {
inputs.declarations.push_str(
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs,
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
@@ -7737,7 +7737,7 @@ impl Renderer {
}
}
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
fn pack_terrain_inputs(&mut self) -> Option<horizon_render_pipeline::GbuffersInputs> {
// Flat draws whenever the device has them (`render` keeps them on with a pack).
let flat = self.flat.is_some() && self.flat_on;
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
@@ -10410,7 +10410,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat
@@ -10443,7 +10443,7 @@ impl Renderer {
self.own_timing.as_mut(),
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
@@ -10475,7 +10475,7 @@ impl Renderer {
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
@@ -10696,7 +10696,7 @@ impl Renderer {
let gpu_occluding = gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding());
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
if let Some((_, group)) = material {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, group, &[]);
}
};
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
@@ -12084,7 +12084,7 @@ impl Renderer {
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
.is_none_or(|n| n.frame.api == horizon_render_pipeline::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -15830,11 +15830,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
let shadow = horizon_render_pipeline::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
..horizon_render_pipeline::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -8,7 +8,8 @@
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
//! pack's frame at group 3.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -369,7 +370,7 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
@@ -398,7 +399,7 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
api: horizon_render_pipeline::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
@@ -417,7 +418,7 @@ impl NativeMaterial {
vertex: bool,
displaced: Option<&DisplacedShape<'_>>,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let writes_surface = horizon_render_pipeline::stages::calls_set_surface(&stage.source);
let module = module(
stage,
shape.sprite_tags,
@@ -586,7 +587,7 @@ impl NativeMaterial {
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
pub(super) fn coverage_test_shader(api: horizon_render_pipeline::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
@@ -829,7 +830,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ horizon_render_pipeline::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -1,7 +1,8 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky, material and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use horizon_render_pipeline::StageKind;
use horizon_shader_api::Report;
use super::displace::Displacement;
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
@@ -253,7 +254,7 @@ impl NativePack {
.transpose()?;
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
let post = (!post.is_empty() || api == horizon_render_pipeline::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
@@ -7,9 +7,9 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use horizon_render_pipeline::stages::{GROUP, POST_TARGET_BINDING};
use horizon_render_pipeline::{Api, Frame as FrameInputs, StageKind};
use horizon_shader_api::check::CheckedStage;
use super::Frame;
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1014,10 +1014,9 @@ impl NativePost {
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
entries.extend(
(0..targets.len() as u32).map(|j| texture_entry(POST_TARGET_BINDING + j, float)),
);
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &entries,
@@ -1346,7 +1345,7 @@ impl NativePost {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
binding: POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
@@ -1497,7 +1496,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
shadow: Option<&horizon_render_pipeline::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -1655,7 +1654,7 @@ pub fn pack_textures(
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
@@ -1684,7 +1683,7 @@ pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEnt
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
@@ -6,8 +6,9 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use horizon_render_pipeline::{Api, Frame as FrameInputs, stages::GROUP};
use horizon_shader_api::manifest::Shadow as Settings;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -387,7 +388,7 @@ fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
)
}
@@ -486,7 +487,7 @@ fn shader_source(
(Some(material), _) => (
format!(
"{material}{}{table}{}{}{GRID_ENTRIES}",
horizon_shader_api::stages::PBR_STUB,
horizon_render_pipeline::stages::PBR_STUB,
super::displace::RELIEF,
super::displace::GRID,
),
@@ -504,7 +505,7 @@ fn shader_source(
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
horizon_render_pipeline::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
@@ -6,7 +6,8 @@
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use horizon_render_pipeline::stages::GROUP;
use horizon_shader_api::check::CheckedStage;
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -40,7 +41,7 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
fn shader_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
@@ -198,7 +199,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let text = shader_source(&stage(own).source, horizon_render_pipeline::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -261,7 +262,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
horizon_render_pipeline::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
@@ -597,9 +597,9 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
terrain: &horizon_render_pipeline::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
) -> [horizon_render_pipeline::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
@@ -619,7 +619,7 @@ pub fn terrain_parts(
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
pub fn terrain_inputs(&self, flat: bool) -> horizon_render_pipeline::GbuffersInputs {
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -753,7 +753,7 @@ impl PackAtlas {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -2,7 +2,7 @@
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
//! dynamic offset at each draw.
use horizon_shaders::{RENDER_STAGES, render_stage};
use horizon_render_pipeline::{RENDER_STAGES, render_stage};
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
@@ -6,7 +6,7 @@
use std::ops::Range;
use horizon_shaders::render_stage;
use horizon_render_pipeline::render_stage;
use super::Vertex;
use super::pack_draw::EntitySpan;
@@ -31,7 +31,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -112,7 +112,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// bright.
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -121,7 +121,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -138,7 +138,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let pull = format!(
" uint hz_pw = hz_vertex_index * {words}u;\n \
@@ -150,7 +150,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
);
horizon_shaders::GeometryInputs {
horizon_render_pipeline::GeometryInputs {
inputs: inputs(),
pull,
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
@@ -163,7 +163,7 @@ pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
pub fn cloud_inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
@@ -183,7 +183,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -17,7 +17,7 @@ const UNIFORM: u64 = 2 * 64;
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
@@ -270,7 +270,7 @@ fn translucent_quad(quad: &[Vertex]) -> bool {
/// outside block entities, and `currentRenderedItemId`.
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage(if translucent {
let stage = horizon_render_pipeline::render_stage(if translucent {
"HAND_TRANSLUCENT"
} else {
"HAND_SOLID"
@@ -4,7 +4,8 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -8,7 +8,7 @@
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
@@ -41,7 +41,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -18,7 +18,8 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -28,8 +29,6 @@ use super::pack_sky::PackSky;
use super::pack_textures;
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
@@ -126,7 +125,7 @@ enum Source {
ShadowDepth(usize, bool),
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
ShadowColor(usize),
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
/// The boats' water patches' depth (`horizon_render_pipeline::WATER_MASK`), for translucent terrain.
WaterMask,
}
@@ -884,13 +883,13 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
/// `hz_depth_linear` for a program: 1 for each of shadowtex0, shadowtex1, `shadow` and
/// `watershadow` whose depth filters (`linear`, bit n: shadowtex n). `shadow` is shadowtex1 in a
/// program that also declares `watershadow` (6.3).
fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4] {
fn depth_linear(interface: &horizon_render_pipeline::Interface, linear: u8) -> [f32; 4] {
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
[0, 1, usize::from(watershadow), 0].map(|n| f32::from(linear >> n & 1))
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -909,7 +908,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -922,8 +921,8 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
horizon_render_pipeline::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1504,7 +1503,7 @@ fn owned_buffers(
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn bindings(
features: wgpu::Features,
interface: &horizon_shaders::Interface,
interface: &horizon_render_pipeline::Interface,
name: &str,
group: Group,
buffers: &[u32],
@@ -1575,12 +1574,12 @@ fn bindings(
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
// own, where a raw texture was bound; this rule is a fit to those cases.
let unbound = t.png
&& t.stage != horizon_shaders::EVERY_STAGE
&& t.stage != horizon_render_pipeline::textures::EVERY_STAGE
&& source(&s.name, false, true, false) == Source::Dummy;
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
|| horizon_render_pipeline::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2284,7 +2283,7 @@ fn build_compute(
device: &wgpu::Device,
name: String,
group: Group,
c: horizon_shaders::ComputeProgram,
c: horizon_render_pipeline::ComputeProgram,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
@@ -2522,7 +2521,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -6066,7 +6065,7 @@ mod tests {
texture("composite", "tr", false),
texture("composite", "gaux2", true),
texture("composite", "noisetex", true),
texture(horizon_shaders::EVERY_STAGE, "cp", true),
texture(horizon_render_pipeline::textures::EVERY_STAGE, "cp", true),
];
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
let interface = horizon_shaders::Interface {
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
use horizon_render_pipeline::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -125,7 +129,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
shadow: &horizon_render_pipeline::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -58,7 +58,7 @@ pub(super) const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
/// What `gbuffers_skybasic` reads from these vertices.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uint hz_v_kind;\n\
layout(set = 1, binding = 0) uniform HzSky { mat4 hz_sky_stars; vec4 hz_sky_color; \
@@ -90,7 +90,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
);
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: vec![
@@ -111,8 +111,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
/// What `gbuffers_skytextured` reads from the sun and moon's vertices.
pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
horizon_shaders::GbuffersInputs {
pub fn celestial_inputs() -> horizon_render_pipeline::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations:
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in vec4 hz_v_color;\n"
@@ -1,7 +1,11 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::Kind;
use horizon_render_pipeline::textures::{
CustomTexture, Dim as TextureDim, EVERY_STAGE, Texels, Texture, TextureSource,
};
use horizon_shaders::Group;
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -118,24 +122,24 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let buffer = horizon_render_pipeline::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
textures
.iter()
.position(|t| t.stage == EVERY_STAGE && t.dimension == dimension && t.name == name)
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
@@ -350,7 +354,7 @@ mod tests {
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let every = EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -2717,7 +2717,7 @@ pub(super) mod initial_history_tests {
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
fn water_patch_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
(k < 2 * horizon_render_pipeline::WATER_PATCHES).then_some(k)
}
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
@@ -2729,7 +2729,7 @@ pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
let mut points: Vec<[f32; 3]> = Vec::new();
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
if let Some(b) = patch_box(points)
&& out.len() < horizon_shaders::WATER_PATCHES
&& out.len() < horizon_render_pipeline::WATER_PATCHES
{
out.push(b);
}
@@ -11,7 +11,7 @@ use super::pack_post::Weather;
/// What `gbuffers_weather` reads from a weather vertex: camera-relative position, the UV in
/// repeats of the sprite, colour (with vanilla's fade) and light. Weather has no normal; the
/// pack sees straight up.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
@@ -31,7 +31,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
horizon_shaders::GbuffersInputs {
horizon_render_pipeline::GbuffersInputs {
declarations,
body,
attributes: attributes
@@ -40,7 +40,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
// Vanilla's weather drops texels below 0.1, as Horizon's own weather shader does.
alpha_test: Some(horizon_shaders::AlphaTest {
alpha_test: Some(horizon_render_pipeline::AlphaTest {
op: ">",
reference: 0.1,
}),
@@ -158,7 +158,7 @@ impl Weather {
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
@@ -225,7 +225,7 @@ struct HorizonWeatherOut {
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
fn native_source(source: &str, api: horizon_render_pipeline::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
@@ -245,7 +245,7 @@ pub(super) fn native_pipeline(
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
use horizon_render_pipeline::stages::GROUP;
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
@@ -322,7 +322,7 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::{Api, stages::StageKind};
use horizon_render_pipeline::{Api, StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
+1 -1
View File
@@ -86,8 +86,8 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_dyes;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
pub mod pack_tags;
pub mod packs;
+4 -1
View File
@@ -766,7 +766,10 @@ fn stem_color(age: u8) -> u32 {
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
}
fn redstone_color(power: u8) -> u32 {
// RedstoneWireBlock.COLORS
// RedstoneWireBlock.COLORS, or a pack's.
if let Some(color) = crate::pack_palette::redstone(power) {
return color;
}
let f = f32::from(power) / 15.0;
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
@@ -1,6 +1,7 @@
//! Polytone's dye and sheep colours (`dye.<name>.diffuse|text`, `sheep.<name>`), for drawing only:
//! the pack recolours collars, tropical fish, banners, wool and sign text, while gameplay (crafting,
//! item data, the beacon) keeps vanilla's `DyeColor`. Lock-free: the draw paths read it per entity.
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use crate::block_entity_render::DYES;
@@ -11,6 +12,7 @@ use horizon_polytone::colors::PackColors;
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
@@ -24,6 +26,7 @@ pub fn set(colors: &PackColors) {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
}
}
@@ -42,6 +45,11 @@ pub fn text(dye: usize) -> u32 {
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
read(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
@@ -69,7 +77,7 @@ mod tests {
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}}"#,
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
@@ -81,7 +89,9 @@ mod tests {
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
@@ -168,6 +168,15 @@ impl Registry {
let mut modifiers = Vec::new();
let mut entity_modifiers = Vec::new();
let mut particle_modifiers = Vec::new();
// Custom colours' `particle.<id>`: a modifier that sets only the colour, below the files'.
for (id, color) in horizon_polytone::colors::PackColors::read(pack_root).particles() {
let json = serde_json::json!({"targets": [id], "color": color});
match ParticleModifier::parse(&json, None) {
Ok(Some(m)) => particle_modifiers.push((m, None, None)),
Ok(None) => {}
Err(e) => log::warn!("colors particle.{id}: {e}"),
}
}
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
@@ -2104,6 +2113,23 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn custom_colours_recolour_their_particle_type() {
let root = pack(&[(
"assets/obs/polytone/colors.json",
serde_json::json!({"particle": {"minecraft:flame": "0x0000ff"}}),
)]);
let registry = Registry::read(&root, Default::default(), blocks());
let colour = |kind: &str| {
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
modify(&registry, kind, None, &mut p);
p.rgba
};
assert_eq!(colour("minecraft:flame"), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(colour("minecraft:smoke"), [1.0; 4]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
let root = pack(&[
+1 -1
View File
@@ -85,7 +85,7 @@ impl SignText {
}
/// `DyeColor.getTextColor` of this side.
pub fn text_color(&self) -> u32 {
crate::pack_dyes::text(usize::from(self.color.min(15)))
crate::pack_palette::text(usize::from(self.color.min(15)))
}
/// `SignText.hasAnyClickCommands`: a line whose style has a `run_command`
/// click event.
@@ -51,6 +51,7 @@ pub fn main() -> horizon_client::Result<()> {
.ok_or("missing cauldron")?,
)?;
let mut item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0; 2],
join: Default::default(),
health: 5,
@@ -73,6 +74,7 @@ pub fn main() -> horizon_client::Result<()> {
no_gravity: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
};
output.push_str(f[0]);
for _ in 0..2 {
+4 -1
View File
@@ -276,8 +276,11 @@ fn concrete_powder_color(dye: &str) -> u32 {
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
/// `RedstoneWireBlock.COLORS`, or a pack's.
fn redstone_color(power: u8) -> [f32; 3] {
if let Some(c) = crate::pack_palette::redstone(power) {
return [16, 8, 0].map(|shift| ((c >> shift) & 255) as f32 / 255.0);
}
let f = f32::from(power) / 15.0;
let r = f * 0.6 + if f > 0.0 { 0.4 } else { 0.3 };
let g = (f * f * 0.7 - 0.5).clamp(0.0, 1.0);
+2 -2
View File
@@ -190,12 +190,12 @@ pub fn banner_color(item: &str) -> Option<&'static str> {
DYES.iter().map(|d| d.0).find(|d| *d == name)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) by dye name, as the resource
/// packs draw it (`pack_dyes`): banner layers, banner and shield items and the
/// packs draw it (`pack_palette`): banner layers, banner and shield items and the
/// loom follow a pack's dye colours as the banner base does.
pub fn dye_rgb(name: &str) -> u32 {
DYES.iter()
.position(|d| d.0 == name)
.map_or(0xFFFFFF, crate::pack_dyes::diffuse)
.map_or(0xFFFFFF, crate::pack_palette::diffuse)
}
/// The layers of a banner or shield stack.
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
+2 -2
View File
@@ -19,8 +19,8 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_dyes, pack_screen, packs,
pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
+58 -85
View File
@@ -82,6 +82,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
}
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
@@ -234,6 +235,12 @@ pub struct ItemEntity {
pub vertical_collision: bool,
/// `Entity.horizontalCollision`: the last move was stopped on x or z.
pub horizontal_collision: bool,
/// `Entity.random`, after the constructor's draws: `moveTowardsClosestSpace`
/// takes an item's escape speed from it.
pub random: horizon_core::random::LegacyRandom,
/// `Entity.fallDistance` (`fall_distance` in its save): what the item has
/// fallen since it last touched ground or water.
pub fall_distance: f64,
}
/// What `Entity.doWaterSplashEffect` asks the level to do for a dropped item.
/// Particles are drawn on the client (`Level.addParticle` is empty on the
@@ -365,6 +372,10 @@ pub struct ItemStep {
did_move: bool,
/// The contact block for a clipped vertical move; idle ticks have none.
pub vertical_contact: Option<BlockPos>,
/// `Entity.move` with physics: where it started, what it moved, and
/// whether the move was applied. `None` while idle or without physics,
/// where `checkFallDamage` does not run.
pub fall_move: Option<(Vec3, Vec3, bool)>,
}
#[cfg(test)]
impl ItemEntity {
@@ -384,6 +395,7 @@ impl ItemEntity {
) -> bool {
let (water, lava) = self.update_fluids(world, fluids);
self.water_entry(water > 0.0, self.ticks == 0, &mut |_| {});
self.lava_fall(lava > 0.0);
let step = self.tick_move(
world,
facts,
@@ -547,14 +559,17 @@ impl ItemEntity {
false,
)
}
/// `Entity.updateFluidInteraction`: entering water after the first tick
/// runs `doWaterSplashEffect`.
/// `Entity.updateFluidInteraction`: water resets the fall, and entering
/// it after the first tick runs `doWaterSplashEffect`.
pub fn water_entry<F: FnMut(ItemSplash)>(
&mut self,
in_water: bool,
first_tick: bool,
splash: &mut F,
) {
if in_water {
self.fall_distance = 0.0;
}
if in_water && !self.was_touching_water && !first_tick {
let mut random = horizon_core::random::LegacyRandom::new(
(self.id as i64)
@@ -684,6 +699,7 @@ impl ItemEntity {
binding: collision,
subject: None,
};
let from = self.position;
let mut wanted = self.velocity;
// Entity.move: a block the item sits in (cobweb, powder snow)
// scales this move once and stops it.
@@ -713,7 +729,8 @@ impl ItemEntity {
)
.unwrap_or(Vec3::ZERO)
};
if move_applies(wanted, moved) {
let applied = move_applies(wanted, moved);
if applied {
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -755,6 +772,7 @@ impl ItemEntity {
old_velocity,
did_move: true,
vertical_contact,
fall_move: (!no_physics).then_some((from, moved, applied)),
};
}
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
@@ -765,6 +783,7 @@ impl ItemEntity {
old_velocity,
did_move: false,
vertical_contact: None,
fall_move: None,
}
}
@@ -828,7 +847,14 @@ impl ItemEntity {
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
self.apply_honey_slide(world, facts);
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
self.stuck = match self.stuck_multiplier(world, facts) {
// Entity.makeStuckInBlock resets the fall.
Some(stuck) => {
self.fall_distance = 0.0;
stuck
}
None => Vec3::ZERO,
};
}
}
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
@@ -2766,35 +2792,7 @@ impl ArmorStand {
}
#[cfg(test)]
mod experience_tests {
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
}
mod experience_tests;
#[cfg(test)]
mod fire_environment_tests {
@@ -2823,34 +2821,19 @@ mod fire_environment_tests {
assets.registry.default_state("minecraft:lava").unwrap(),
)
.unwrap();
let make = |id: &str| ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
let make = |id: &str| {
ItemEntity::new(
1,
ItemStack {
item: std::sync::Arc::from(id),
count: 1,
damage: 0,
extra: None,
},
at,
Vec3::ZERO,
0.0,
)
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
@@ -2958,30 +2941,7 @@ mod destroyed_bundle_environment_tests {
ItemStack::new("minecraft:stone", 3),
ItemStack::new("minecraft:diamond", 1),
];
let mut bundle = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
thrower: None,
target: None,
stack,
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
};
let mut bundle = ItemEntity::new(1, stack, at, Vec3::ZERO, 0.0);
// 5 health against 4 a tick: the first tick leaves 1 and nothing out.
let first = bundle.environment_tick(&world, &facts, assets.registry, &items);
assert!(first.alive && first.spilled.is_empty());
@@ -3165,6 +3125,7 @@ mod merge_tests {
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
let at = Vec3::new(x, 64.0, 0.5);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3185,6 +3146,7 @@ mod merge_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3684,6 +3646,7 @@ mod honey_slide_tests {
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3704,6 +3667,7 @@ mod honey_slide_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3835,6 +3799,7 @@ mod slime_bounce_tests {
fn grounded_item() -> ItemEntity {
let at = Vec3::new(0.5, 2.0, 0.5);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -3855,6 +3820,7 @@ mod slime_bounce_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
@@ -3906,6 +3872,7 @@ mod slime_bounce_tests {
old_velocity: item.velocity,
did_move: true,
vertical_contact: None,
fall_move: None,
},
);
assert_eq!(item.velocity.x.to_bits(), 0x3fb9_1687_3333_3334);
@@ -4747,6 +4714,7 @@ mod item_destroyed_tests {
.map(|(id, count)| ItemStack::new(id, *count))
.collect();
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4762,6 +4730,7 @@ mod item_destroyed_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -4982,6 +4951,7 @@ mod dropped_item_light_tests {
fn item(previous_y: f64, y: f64) -> ItemEntity {
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -5002,6 +4972,7 @@ mod dropped_item_light_tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -5044,6 +5015,7 @@ mod dropped_item_light_tests {
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
// Across x and z too, not just down.
let sliding = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
position: Vec3::new(1.5, 64.0, 0.5),
previous: Vec3::new(0.5, 64.0, 0.5),
..item(64.0, 64.0)
@@ -5061,6 +5033,7 @@ mod dropped_item_light_tests {
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
let burning = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
fire_ticks: 160,
..item(64.0, 64.0)
};
@@ -0,0 +1,27 @@
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
@@ -338,6 +338,7 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -358,6 +359,7 @@ mod tests {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -0,0 +1,62 @@
//! A dropped item's construction and its fall distance (`Entity.fallDistance`).
use super::{ItemEntity, ItemStep};
use crate::items::ItemStack;
use horizon_core::geometry::Vec3;
impl ItemEntity {
/// A fresh `ItemEntity` at `position` moving at `velocity`: full health,
/// no age, delay or owner, not yet ticked.
pub fn new(id: u32, stack: ItemStack, position: Vec3, velocity: Vec3, bob_offset: f32) -> Self {
Self {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id,
stack,
position,
previous: position,
velocity,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
// The caller gives it the own random that drew its UUID and bobOffs.
random: horizon_core::random::LegacyRandom::new(0),
}
}
/// `Entity.move`'s fall bookkeeping after the item's move: `reset_clip`
/// is the cast of a move of a block or more over fall-resetting blocks
/// and water (the fall it leaves), then `checkFallDamage` adds the drop,
/// narrowed to a float, outside water and ends the fall on the ground.
pub fn check_fall(&mut self, step: &ItemStep, reset_clip: impl FnOnce(f64, Vec3, Vec3) -> f64) {
let Some((from, moved, applied)) = step.fall_move else {
return;
};
if applied {
self.fall_distance = reset_clip(self.fall_distance, from, moved);
}
if !self.was_touching_water && moved.y < 0.0 {
self.fall_distance -= f64::from(moved.y as f32);
}
if self.on_ground {
self.fall_distance = 0.0;
}
}
/// `Entity.baseTick`: lava halves the fall each tick.
pub fn lava_fall(&mut self, in_lava: bool) {
if in_lava {
self.fall_distance *= 0.5;
}
}
}
+44 -156
View File
@@ -22,6 +22,8 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use std::{collections::HashMap, sync::Arc};
mod projectile_fields;
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
mod saved_attributes;
/// Saved data carried by `ConversionType.convertCommon` outside the modelled
@@ -1362,14 +1364,7 @@ pub fn mob_to_nbt(
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
map.insert("RoarTick".into(), Tag::Int(m.extra.anim_ticks));
}
"minecraft:vex" => {
if let Some(h) = m.extra.home {
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
}
if m.extra.egg_time > 0 {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:vex" => crate::mobs::vex::write_nbt(m, map, &mut uuid_of),
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
@@ -1562,11 +1557,9 @@ pub fn mob_to_nbt(
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(attr_id.as_str()))
{
// The model owns these bases: a ravager's speed, a wolf's health.
if matches!(
(species, attr),
(S::Ravager, Attr::MovementSpeed) | (S::Wolf, Attr::MaxHealth)
) && let Tag::Compound(c) = existing
// The model owns a wolf's health base.
if (species, attr) == (S::Wolf, Attr::MaxHealth)
&& let Tag::Compound(c) = existing
{
c.insert("base".into(), Tag::Double(base.to_bits()));
}
@@ -2642,22 +2635,12 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
if crate::mobs::ravager::immobile(&m)
&& !matches!(tag.get("attributes"), Some(Tag::List(l)) if l.iter().any(|a| text(a.get("id")).as_deref() == Some("minecraft:movement_speed")))
{
m.attrs.speed = 0.0;
}
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
{
m.extra.home = Some([v[0], v[1], v[2]]);
}
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
crate::mobs::vex::read_nbt(&mut m, tag);
}
// Bogged.readAdditionalSaveData: unsheared when absent.
if m.species() == crate::mobs::Species::Bogged {
@@ -2822,7 +2805,7 @@ fn item_entity_to_nbt_saved(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>, i
motion: e.velocity,
rotation: e.rotation,
on_ground: e.on_ground,
fall_distance: 0.0,
fall_distance: e.fall_distance,
};
let mut tag = base.non_mob_tag(raw);
for (key, value) in [
@@ -2998,6 +2981,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
on_ground: flag(tag, "OnGround"),
vertical_collision: false,
horizontal_collision: false,
// Entity.load: getDoubleOr, any number widened.
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
age: number(tag.get("Age")).unwrap_or(0) as i32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0),
bob_offset,
@@ -3005,6 +2990,8 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
// The caller gives it the own random that drew its UUID and bobOffs.
random: horizon_core::random::LegacyRandom::new(0),
})
}
@@ -3285,7 +3272,7 @@ pub fn fireball_to_nbt(
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: p.on_ground,
fall_distance: 0.0,
fall_distance: p.fall_distance,
};
let mut tag = base.tag(raw);
put(
@@ -3312,27 +3299,6 @@ pub fn fireball_to_nbt(
Some(tag)
}
/// `Entity.load`: fire is read as a short, air as an int and the visual
/// fire flag as a byte-valued boolean. Portal state lives on the level.
fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
p.has_visual_fire = flag(tag, "HasVisualFire");
}
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
/// only write the optional visual fire flag when it is true.
fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
put(tag, "Air", Tag::Short(p.air_supply as i16));
if let Tag::Compound(map) = tag {
map.remove(&"HasVisualFire".into());
}
if p.has_visual_fire {
put(tag, "HasVisualFire", Tag::Byte(1));
}
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// the explicit HasBeenShot flag.
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
@@ -3436,7 +3402,7 @@ pub fn throwable_to_nbt(
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: p.on_ground,
fall_distance: 0.0,
fall_distance: p.fall_distance,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
@@ -3622,6 +3588,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
load_projectile_entity_fields(&mut p, tag);
}
p.on_ground = flag(tag, "OnGround");
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
if let Some(item) = tag.get("Item").and_then(item_from_nbt) {
p.set_item(item);
@@ -4541,74 +4508,7 @@ mod tnt_power_tests {
}
#[cfg(test)]
mod spell_ticks_nbt_tests {
use super::*;
use crate::mobs::Species;
fn caster(species: Species, name: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = format!("minecraft:{name}");
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
/// counter already used by casting goals and customServerAiStep.
#[test]
fn spell_ticks_load_into_the_live_counter() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let m = caster(species, name);
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
for value in [None, Some(30), Some(0), Some(-2)] {
let mut raw = Tag::compound([
("id", string(&m.kind.id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
if let Some(value) = value {
put(&mut raw, "SpellTicks", Tag::Int(value));
}
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded.extra.counter,
value.unwrap_or(0),
"{name}: {value:?}"
);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
}
}
}
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
/// no raw input exists or the original raw counter has become stale.
#[test]
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let mut m = caster(species, name);
let raw = Tag::compound([
("SpellTicks", Tag::Int(30)),
("audit:unrelated", Tag::Int(7)),
]);
for value in [29, 27, 20, 0, -2] {
m.extra.counter = value;
for raw in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
}
mod spell_ticks_nbt_tests;
#[cfg(test)]
mod tests {
@@ -5721,33 +5621,25 @@ mod tests {
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
count: 1,
damage: 5,
extra: None,
},
position: Vec3::new(100.5, 64.0, -31.5),
previous: Vec3::ZERO,
velocity: Vec3::new(0.0, -0.04, 0.0),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 120,
pickup_delay: 10,
ticks: 0,
bob_offset: 0.0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
..ItemEntity::new(
7,
ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
count: 1,
damage: 5,
extra: None,
},
Vec3::new(100.5, 64.0, -31.5),
Vec3::new(0.0, -0.04, 0.0),
0.0,
)
};
let tag = item_entity_to_nbt(&e, [1, 2, 3, 4], None);
assert_eq!(kind(&tag).as_deref(), Some("minecraft:item"));
@@ -10511,25 +10403,21 @@ mod equine_temper_preservation_tests {
);
Arc::make_mut(&mut kind).species = Species::Ravager;
let types = [("minecraft:ravager".into(), kind)].into();
let mut ravager = mob_from_nbt(
&Tag::compound([
("id", string("minecraft:ravager")),
("Pos", doubles(&[0.0, 0.0, 0.0])),
("RoarTick", Tag::Int(10)),
]),
&types,
2,
None,
)
.unwrap();
ravager.age = 1;
let saved = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
assert_eq!(
saved.get("attributes"),
Some(&Tag::List(vec![Tag::compound([
("id", string("minecraft:movement_speed")),
("base", Tag::Double(0.0f64.to_bits()))
])]))
);
// Before its first aiStep a roaring ravager has no movement_speed
// instance; a saved base (mid-ramp after a roar) loads back as is.
let speed = Tag::List(vec![Tag::compound([
("id", string("minecraft:movement_speed")),
("base", Tag::Double(0.10317f64.to_bits())),
])]);
for (key, value, saved) in [
("RoarTick", Tag::Int(10), Tag::List(vec![])),
("attributes", speed.clone(), speed),
] {
let pos = ("Pos", doubles(&[0.0, 0.0, 0.0]));
let tag = Tag::compound([("id", string("minecraft:ravager")), pos, (key, value)]);
let ravager = mob_from_nbt(&tag, &types, 2, None).unwrap();
let nbt = mob_to_nbt(&ravager, [0; 4], None, None, |_| None);
assert_eq!(nbt.get("attributes"), Some(&saved));
}
}
}
@@ -0,0 +1,28 @@
//! The `Entity` fields a projectile keeps beside `Projectile`'s own: fire,
//! air, the visual fire flag and the fall distance.
use super::{flag, float, int_value, put};
use crate::projectiles::Projectile;
use horizon_world::nbt::Tag;
/// `Entity.load`: fire is read as a short, air as an int and the visual
/// fire flag as a byte-valued boolean. Portal state lives on the level.
pub(super) fn load_projectile_entity_fields(p: &mut Projectile, tag: &Tag) {
p.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(p.air_supply);
p.has_visual_fire = flag(tag, "HasVisualFire");
// Entity.load: getDoubleOr, any number widened.
p.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
}
/// `Entity.saveWithoutId`: save the current fire and air as shorts, and
/// only write the optional visual fire flag when it is true.
pub(super) fn put_projectile_entity_fields(tag: &mut Tag, p: &Projectile) {
put(tag, "Fire", Tag::Short(p.fire_ticks as i16));
put(tag, "Air", Tag::Short(p.air_supply as i16));
if let Tag::Compound(map) = tag {
map.remove(&"HasVisualFire".into());
}
if p.has_visual_fire {
put(tag, "HasVisualFire", Tag::Byte(1));
}
}
@@ -0,0 +1,66 @@
use super::*;
use crate::mobs::Species;
fn caster(species: Species, name: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = format!("minecraft:{name}");
Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
/// `SpellcasterIllager.readAdditionalSaveData`: bind loaded values to the
/// counter already used by casting goals and customServerAiStep.
#[test]
fn spell_ticks_load_into_the_live_counter() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let m = caster(species, name);
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
for value in [None, Some(30), Some(0), Some(-2)] {
let mut raw = Tag::compound([
("id", string(&m.kind.id)),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
if let Some(value) = value {
put(&mut raw, "SpellTicks", Tag::Int(value));
}
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded.extra.counter,
value.unwrap_or(0),
"{name}: {value:?}"
);
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value.unwrap_or(0))));
}
}
}
/// `SpellcasterIllager.addAdditionalSaveData`: live state wins even when
/// no raw input exists or the original raw counter has become stale.
#[test]
fn spell_ticks_save_the_mutated_model_without_a_raw_fallback() {
for (species, name) in [
(Species::Evoker, "evoker"),
(Species::Illusioner, "illusioner"),
] {
let mut m = caster(species, name);
let raw = Tag::compound([
("SpellTicks", Tag::Int(30)),
("audit:unrelated", Tag::Int(7)),
]);
for value in [29, 27, 20, 0, -2] {
m.extra.counter = value;
for raw in [None, Some(&raw)] {
let saved = mob_to_nbt(&m, [1, 2, 3, 4], raw, None, |_| None);
assert_eq!(saved.get("SpellTicks"), Some(&Tag::Int(value)), "{name}");
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
}
}
+13 -36
View File
@@ -3893,8 +3893,7 @@ impl Game<'_> {
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
let item = stack.item.clone();
let count = stack.count as i32;
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
@@ -3910,27 +3909,15 @@ impl Game<'_> {
});
self.dropped.push(ItemEntity {
rotation: [yaw, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: self.next_entity,
stack,
position: p,
previous: p,
velocity: Vec3::new(velocity[0], velocity[1], velocity[2]),
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: i64::from(pickup_delay),
bob_offset: bob,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
random: own,
..ItemEntity::new(
self.next_entity,
stack,
p,
Vec3::new(velocity[0], velocity[1], velocity[2]),
bob,
)
});
// ServerLevel.addFreshEntity.
if self.native_joins_flush(false, self.next_entity) {
@@ -5583,20 +5570,10 @@ impl Game<'_> {
}
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
// InfestedBlock.spawnAfterBreak, unless the tool has silk touch
// (#prevents_infested_spawns).
if facts.name.starts_with("minecraft:infested_") && !silk {
self.spawn_infestation(pos);
}
// Block.tryDropExperience samples before Silk Touch removes the XP.
// SpawnerBlock.spawnAfterBreak ignores Silk Touch. Both sample even
+1 -1
View File
@@ -677,7 +677,7 @@ impl Game<'_> {
0.0
}
}
/// `Entity.move`'s fall reset for a vehicle that keeps a fall and moves on
/// `Entity.move`'s fall reset for an entity that keeps a fall and moves on
/// by `moved` from `from`: a move of a block or more casts up to 8 blocks
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
/// and a hit resets the fall, before the move's own drop counts.
@@ -7,8 +7,15 @@ use std::sync::{Arc, Mutex};
/// Exercise the actual Game keyboard/mouse handlers and generated mod dispatch.
#[test]
#[ignore = "needs prepared resources and a fresh HORIZON_HOME; run in isolation"]
#[ignore = "needs prepared resources and a fresh HORIZON_HOME"]
fn chat_completion_reaches_mods_from_keyboard_and_mouse() {
crate::mod_api::in_own_process(
"game::chat::suggestion_completion_tests::chat_completion_reaches_mods_from_keyboard_and_mouse",
chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process,
);
}
fn chat_completion_reaches_mods_from_keyboard_and_mouse_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let scratch = PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("suggestion-game");
std::fs::create_dir_all(&scratch).unwrap();
@@ -471,9 +471,10 @@ impl Game<'_> {
Target::Item(id) => {
let new = entity_nbt::item_entity_from_nbt(&tag, id, 0.0).ok_or_else(stale)?;
if let Some(slot) = self.dropped.iter_mut().find(|e| e.id == id) {
let bob = slot.bob_offset;
*slot = new;
slot.bob_offset = bob;
// `/data` loads into the same entity: its bobOffs and own random stay.
let kept = std::mem::replace(slot, new);
slot.bob_offset = kept.bob_offset;
slot.random = kept.random;
}
}
Target::Fangs(id) => {
@@ -13,6 +13,7 @@ use std::sync::Arc;
fn item(id: u32, at: Vec3) -> ItemEntity {
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: true,
@@ -33,6 +34,7 @@ fn item(id: u32, at: Vec3) -> ItemEntity {
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 97,
pickup_delay: 0,
bob_offset: 0.0,
+4
View File
@@ -592,6 +592,7 @@ impl Game<'_> {
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
item.lava_fall(heights.1 > 0.0);
let step = item.tick_move_before_block_effects(
&self.world,
self.res.facts(),
@@ -603,6 +604,9 @@ impl Game<'_> {
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
);
item.check_fall(&step, |fall, from, moved| {
self.vehicle_fall_reset(fall, from, moved)
});
if let Some(pos) = step.vertical_contact {
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
+173 -28
View File
@@ -303,6 +303,43 @@ impl Game<'_> {
}
hits as f32 / count as f32
}
/// What a ray's step through the block at `pos` takes from its power
/// (`ExplosionDamageCalculator.getBlockExplosionResistance`), and whether
/// the block is spared.
fn blast_step<S: std::hash::BuildHasher>(
&self,
pos: [i32; 3],
resists: Option<([i32; 3], f32)>,
cap: Option<f32>,
wither_immune: Option<&std::collections::HashSet<String, S>>,
) -> (f32, bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
};
// A primed TNT minecart's rails and the blocks they
// stand on: no resistance, and never blown up.
let spared = self.combat.rail_proof_blast
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
let taken = match resists {
Some((at, resistance)) if at == pos => (resistance + 0.3) * 0.3,
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = if spared {
0.0
} else {
f.explosion_resistance.max(fluid)
};
let immune = wither_immune.is_some_and(|t| t.contains(&*f.name));
(blast_resistance(base, cap, immune) + 0.3) * 0.3
}
_ => 0.0,
};
(taken, spared)
}
/// `ServerExplosion.calculateExplodedPositions`; `resists` overrides one
/// block's resistance (`ExplosionDamageCalculator.getBlockExplosionResistance`).
fn exploded_positions(
@@ -341,6 +378,11 @@ impl Game<'_> {
zd /= d;
let mut power = radius * (0.7 + self.world.random.next_float() * 0.6);
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
// A ray's steps are 0.3 long, about three to a block: a
// step in the block the last one was in takes what that
// one took (the level does not change while rays cast),
// and that block is in `set` already if it is to be.
let mut last: Option<([i32; 3], f32, bool)> = None;
while power > 0.0 {
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
if pos[1] < min_y
@@ -350,35 +392,17 @@ impl Game<'_> {
{
break;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
let (taken, spared, again) = match last {
Some((at, taken, spared)) if at == pos => (taken, spared, true),
_ => {
let (taken, spared) =
self.blast_step(pos, resists, cap, wither_immune.as_ref());
last = Some((pos, taken, spared));
(taken, spared, false)
}
};
// A primed TNT minecart's rails and the blocks they
// stand on: no resistance, and never blown up.
let spared = self.combat.rail_proof_blast
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
match resists {
Some((at, resistance)) if at == pos => {
power -= (resistance + 0.3) * 0.3;
}
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = if spared {
0.0
} else {
f.explosion_resistance.max(fluid)
};
let immune =
wither_immune.as_ref().is_some_and(|t| t.contains(&*f.name));
power -= (blast_resistance(base, cap, immune) + 0.3) * 0.3;
}
_ => {}
}
if power > 0.0 && !spared {
power -= taken;
if power > 0.0 && !spared && !again {
set.put(Key(pos), ());
}
xp += xd * f64::from(0.3f32);
@@ -1047,6 +1071,22 @@ impl Game<'_> {
self.lan_explode(center, small, radius, block_count, pushed);
Ok(())
}
/// `InfestedBlock.spawnAfterBreak` without a tool that prevents it: while
/// blocks drop, its silverfish (`spawnInfestation`) comes out at the
/// bottom centre of the block, with its spawn animation.
pub(super) fn spawn_infestation(&mut self, pos: [i32; 3]) {
if !self.ui.rules.bool("block_drops") {
return;
}
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob_raw("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
fn explosion_hit_block(
&mut self,
@@ -1091,6 +1131,10 @@ impl Game<'_> {
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
// Block.onExplosionHit runs spawnAfterBreak before the drops.
if !is_tnt && name.starts_with("minecraft:infested_") {
self.spawn_infestation(pos);
}
if !is_tnt {
let dynamic = self.pending.dynamic(pos);
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
@@ -1260,6 +1304,107 @@ fn blast_resistance(base: f32, cap: Option<f32>, wither_immune: bool) -> f32 {
mod tests {
use super::*;
/// `exploded_positions` before rays reused their last block's lookup:
/// every step looked its block up.
fn exploded_positions_stepwise(
game: &mut Game<'_>,
center: [f64; 3],
radius: f32,
resists: Option<([i32; 3], f32)>,
) -> Vec<[i32; 3]> {
let (min_y, max_y) = game.with_level(|_, level, _| {
use horizon_world::level::LevelAccess;
(level.min_y(), level.max_y())
});
let mut set: JavaHashMap<Key, ()> = JavaHashMap::default();
for xx in 0..16 {
for yy in 0..16 {
for zz in 0..16 {
if !(xx == 0 || xx == 15 || yy == 0 || yy == 15 || zz == 0 || zz == 15) {
continue;
}
let mut xd = f64::from(xx as f32 / 15.0 * 2.0 - 1.0);
let mut yd = f64::from(yy as f32 / 15.0 * 2.0 - 1.0);
let mut zd = f64::from(zz as f32 / 15.0 * 2.0 - 1.0);
let d = (xd * xd + yd * yd + zd * zd).sqrt();
(xd, yd, zd) = (xd / d, yd / d, zd / d);
let mut power = radius * (0.7 + game.world.random.next_float() * 0.6);
let (mut xp, mut yp, mut zp) = (center[0], center[1], center[2]);
while power > 0.0 {
let pos = [xp.floor() as i32, yp.floor() as i32, zp.floor() as i32];
if pos[1] < min_y || pos[1] >= max_y {
break;
}
let state = game.world.block(pos[0], pos[1], pos[2]);
let f = game.res.facts().get(state);
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
0.0
};
match resists {
Some((at, resistance)) if at == pos => {
power -= (resistance + 0.3) * 0.3;
}
_ if !game.res.facts().is_air(state) || f.fluid != Fluid::None => {
let base = f.explosion_resistance.max(fluid);
power -= (blast_resistance(base, None, false) + 0.3) * 0.3;
}
_ => {}
}
if power > 0.0 {
set.put(Key(pos), ());
}
xp += xd * f64::from(0.3f32);
yp += yd * f64::from(0.3f32);
zp += zd * f64::from(0.3f32);
power -= 0.225_000_01;
}
}
}
}
set.entries().map(|(k, _)| k.0).collect()
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn rays_reusing_their_last_block_explode_what_each_step_did() {
let mut game = crate::game::native_machines::tests::server_game();
let registry = &game.pack.assets.registry;
let states: Vec<BlockStateId> = [
"minecraft:stone",
"minecraft:air",
"minecraft:water",
"minecraft:dirt",
"minecraft:obsidian",
"minecraft:air",
"minecraft:oak_planks",
"minecraft:glass",
]
.iter()
.map(|n| registry.default_state(n).unwrap())
.collect();
// A jumble of blocks of every resistance around the blast, in the
// loaded chunk at the origin.
for x in 0..16 {
for z in 0..16 {
for y in 56..74 {
let i = ((x * 7 + y * 3 + z * 5) as usize) % states.len();
game.world.set(x, y, z, states[i]).unwrap();
}
}
}
let center = [8.3, 64.6, 7.9];
for (radius, resists) in [(4.0, None), (6.0, None), (4.0, Some(([8, 64, 8], 1200.0)))] {
game.world.random = horizon_core::random::LegacyRandom::new(7);
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
game.world.random = horizon_core::random::LegacyRandom::new(7);
let got = game.exploded_positions(center, radius, resists);
assert!(expected.len() > 20, "{radius}: {}", expected.len());
assert_eq!(got, expected, "radius {radius}, {resists:?}");
}
}
#[test]
#[ignore = "requires prepared resources; tools/test_explosion_items_parity.py"]
fn explosion_items_parity_fixture() {
@@ -105,8 +105,10 @@ impl Game<'_> {
);
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
f.on_ground = on_ground;
if next.y < f.position.y {
f.fall_distance += f.position.y - next.y;
// Entity.checkFallDamage: each step down, narrowed to a float (a
// falling block never runs baseTick, so it is never in water).
if movement.y < 0.0 {
f.fall_distance -= f64::from(movement.y as f32);
}
let from = f.position;
f.position = next;
@@ -2176,6 +2176,7 @@ mod tests {
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -2191,6 +2192,7 @@ mod tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -37,8 +37,15 @@ const FISH: [&str; 4] = [
];
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium() {
crate::mod_api::in_own_process(
"game::fishing::medium_tests::a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium",
a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process,
);
}
fn a_bobber_floats_bites_and_rolls_the_table_of_a_mods_medium_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -26,8 +26,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_changed_inventory_is_heard_once_with_its_41_slots() {
crate::mod_api::in_own_process(
"game::advancements::inventory_changed_tests::a_changed_inventory_is_heard_once_with_its_41_slots",
a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process,
);
}
fn a_changed_inventory_is_heard_once_with_its_41_slots_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -168,6 +168,7 @@ mod tests {
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
let p = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
health: 5,
@@ -190,6 +191,7 @@ mod tests {
no_gravity: false,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
}
}
@@ -32,8 +32,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity() {
crate::mod_api::in_own_process(
"game::item_contact_tests::the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity",
the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process,
);
}
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -82,7 +82,8 @@ impl Game<'_> {
&nearby,
Some(self.border.geometry()),
);
let escape_speed = no_physics.then(|| self.world.random.next_float() * 0.2 + 0.1);
// `moveTowardsClosestSpace` draws the speed from the item's own random.
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
let mut entered = Vec::new();
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
@@ -573,3 +574,47 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod escape_tests {
use super::super::native_machines::tests::server_game;
use horizon_client::chunk::Chunk;
use horizon_client::items::ItemStack;
use horizon_core::random::RandomSource;
use std::sync::Arc;
/// `Entity.moveTowardsClosestSpace` takes an item's escape speed from the
/// item's own random, the one its constructor drew from, and leaves the
/// level's random as it was (#13043).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn an_item_escapes_a_block_at_its_own_randoms_speed() {
let mut game = server_game();
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.update_chunk_tickets();
assert!(game.entity_ticking_area()(8, 8));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 64, 8, stone).unwrap();
let stack = ItemStack::new("minecraft:stone", 2);
game.spawn_item(stack, [8.5, 64.4, 8.5], [0.0, 0.0, 0.0], 10);
let item = game.dropped.last().unwrap();
let expected = f64::from(item.random.clone().next_float() * 0.2 + 0.1);
let mut level = game.world.random.clone();
game.ticked_entities_through = u32::MAX;
game.tick_items();
let item = game.dropped.last().unwrap();
// Up is the nearest open side from the middle of the block.
// The push replaces the fall's velocity; then the 0.98 drag.
assert_eq!(item.velocity.y, expected * 0.98);
assert_eq!(game.world.random.next_float(), level.next_float());
}
}
@@ -75,8 +75,15 @@ fn target(id: i32) -> Option<InteractTarget> {
}
#[test]
#[ignore = "private prepared resources; process-wide mods, run this test alone"]
#[ignore = "private prepared resources in HORIZON_TEST_RESOURCES"]
fn actual_item_interact_mob_fallback_priority_and_filters() {
crate::mod_api::in_own_process(
"game::item_interact_mob_tests::actual_item_interact_mob_fallback_priority_and_filters",
actual_item_interact_mob_fallback_priority_and_filters_in_own_process,
);
}
fn actual_item_interact_mob_fallback_priority_and_filters_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
@@ -35,8 +35,15 @@ fn porkchops(game: &Game<'_>) -> usize {
}
#[test]
#[ignore = "run alone with prepared resources in HORIZON_TEST_RESOURCES"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once() {
crate::mod_api::in_own_process(
"game::kill_die_cancel_tests::a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once",
a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process,
);
}
fn a_vetoed_kill_keeps_the_mob_and_a_later_kill_dies_once_in_own_process() {
let probe = Arc::new(Probe::default());
assert!(
mod_api::MODS
+163 -1
View File
@@ -485,10 +485,93 @@ impl Game<'_> {
e.pitch = o.pitch;
e.head_yaw = o.yaw;
if let Some(tag) = self.persist.raw.get(&o.id) {
self.lan_display_data(tag, hub, e);
self.lan_display_data_kept(o.id, tag, hub, e);
}
}
// Only this level's displays: the others are kept for their own level's pass.
KEPT_DISPLAYS.with(|kept| {
let mut kept = kept.borrow_mut();
let Some(kept) = kept.get_mut(&self.dim) else {
return;
};
if kept.len() > self.others.len() + 64 {
let ids: horizon_client::fast_hash::FxHashSet<u32> =
self.others.iter().map(|o| o.id).collect();
kept.retain(|id, _| ids.contains(id));
}
});
}
/// [`Self::lan_display_data`] for the display `id`, built again only when
/// its compound changed: a server's holograms and menus are text displays
/// whose text, item and transformation were parsed again every tick.
fn lan_display_data_kept(&self, id: u32, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
if !matches!(
e.kind.as_str(),
"minecraft:text_display" | "minecraft:block_display" | "minecraft:item_display"
) {
return self.lan_display_data(tag, hub, e);
}
// Block states are read through the pack's registry: a kept build is
// only the same for the same one.
let registry = std::ptr::from_ref(self.pack.assets.registry) as usize;
KEPT_DISPLAYS.with(|kept| {
let mut kept = kept.borrow_mut();
let kept = kept.entry(self.dim).or_default();
let same =
|k: &KeptDisplay| k.registry == registry && k.kind == e.kind && k.tag == *tag;
if let Some(k) = kept.get(&id).filter(|k| same(k)) {
e.meta.clone_from(&k.meta);
e.defaults.clone_from(&k.defaults);
// The stack's ids are the hub's: asked each time, as a build does.
if let Some((at, item)) = &k.item
&& let Some(slot) = e.meta.get_mut(*at)
{
slot.2 = Value::Item(hub.stack(item));
}
return;
}
self.lan_display_data(tag, hub, e);
let item = (e.kind == "minecraft:item_display").then(|| {
let at = e
.meta
.iter()
.position(|m| m.0 == 23 && m.1 == serializer::ITEM_STACK);
let item = tag
.get("item")
.and_then(horizon_client::save::item_from_nbt);
(at.unwrap_or(usize::MAX), item)
});
kept.insert(
id,
KeptDisplay {
registry,
kind: e.kind.clone(),
tag: tag.clone(),
meta: e.meta.clone(),
defaults: e.defaults.clone(),
item,
},
);
});
}
}
/// A display's synched data as [`Game::lan_display_data`] built it from
/// `tag`, kept for the display's id on the thread that tracks entities, per
/// level: a server thread tracks each dimension's `others` in turn.
struct KeptDisplay {
registry: usize,
kind: String,
tag: Tag,
meta: Vec<(u8, i32, Value)>,
defaults: Vec<(u8, i32, Value)>,
/// An item display's item and the index in `meta` its stack goes in.
item: Option<(usize, Option<horizon_client::items::ItemStack>)>,
}
thread_local! {
static KEPT_DISPLAYS: std::cell::RefCell<
horizon_client::fast_hash::FxHashMap<super::Dim, horizon_client::fast_hash::FxHashMap<u32, KeptDisplay>>,
> = Default::default();
}
impl Game<'_> {
@@ -935,6 +1018,85 @@ fn push_arrow_meta(
mod tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn kept_display_data_is_what_a_build_gives() {
let game = crate::game::native_machines::tests::server_game();
let sync = std::sync::Arc::new(horizon_client::lan::data::SyncData::empty());
let net = horizon_client::lan::NetConfig {
listen: false,
..horizon_client::lan::NetConfig::loopback(0, String::new(), 2)
};
let lan = horizon_client::lan::Lan::start_with(sync.clone(), net).unwrap();
let encoder = horizon_client::lan::chunks::ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let config = horizon_client::lan::hub::HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: true,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, config);
let snbt = |s: &str| horizon_client::command::snbt::parse_fully(s).unwrap();
let displays: [(&str, Tag, Tag); 3] = [
(
"minecraft:text_display",
snbt(r#"{text:{text:"Shop",color:"gold"},billboard:"center",line_width:120}"#),
snbt(r#"{text:{text:"Sold out",bold:1b},billboard:"center",background:0}"#),
),
(
"minecraft:block_display",
snbt(r#"{block_state:{Name:"minecraft:oak_stairs",Properties:{facing:"east"}}}"#),
snbt(r#"{block_state:{Name:"minecraft:glass"},brightness:{sky:15,block:3}}"#),
),
(
"minecraft:item_display",
snbt(r#"{item:{id:"minecraft:diamond",count:3},item_display:"gui"}"#),
snbt(
r#"{item:{id:"minecraft:apple",count:1},item_display:"head",glow_color_override:255}"#,
),
),
];
let state = |kind: &str| EntityState {
kind: kind.into(),
..Default::default()
};
for (id, (kind, first, second)) in displays.iter().enumerate() {
let id = 900 + id as u32;
// Ticks with the first compound, then the second, then the first
// again: each answer is a fresh build's.
for tag in [first, first, first, second, second, first] {
let mut built = state(kind);
game.lan_display_data(tag, &mut hub, &mut built);
assert!(!built.meta.is_empty(), "{kind}");
let mut kept = state(kind);
game.lan_display_data_kept(id, tag, &mut hub, &mut kept);
assert_eq!(
format!("{:?}", kept.meta),
format!("{:?}", built.meta),
"{kind}"
);
assert_eq!(
format!("{:?}", kept.defaults),
format!("{:?}", built.defaults),
"{kind}"
);
}
}
}
#[test]
fn summoned_ids_are_unique_and_survive_the_tracking_pass() {
let mut next = 40;
+160 -46
View File
@@ -12,12 +12,31 @@
use horizon_client::fast_hash::FxHashMap;
use horizon_client::lan::{hub::Hub, tracking};
/// Per level, each idle entity's place and the tracking pass it was last described in.
/// Per level, the idle entities the last pass noted (`Kept`).
#[derive(Default)]
pub(super) struct IdleStates {
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
levels: FxHashMap<String, Kept>,
}
/// One level's idle entities as the last tracking pass noted them, in the
/// order it met them, and that pass. The game meets its entities in the same
/// order every tick (its lists keep their order), so a pass finds each one's
/// note at or just past where it found the last one: no lookup by id. One it
/// does not find there is described in full, which is always right.
#[derive(Default)]
struct Kept {
pass: u32,
ids: Vec<i32>,
places: Vec<Place>,
/// This pass's notes, which replace them when it finishes.
next_ids: Vec<i32>,
next_places: Vec<Place>,
}
/// How many notes past the last one found a pass looks: entities that left
/// the level since are skipped over.
const LOOK_AHEAD: usize = 4;
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
/// it is on the ground, and its velocity.
#[derive(Clone, Copy, PartialEq)]
@@ -30,16 +49,24 @@ pub(super) struct Place {
/// The idle entities of the level in place, for one pass.
pub(super) struct IdlePass<'a> {
kept: &'a mut FxHashMap<i32, (u32, Place)>,
kept: &'a mut Kept,
/// Whether the kept notes are the last pass's.
last: bool,
/// Where the next note is looked for.
cursor: usize,
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
/// described one pass earlier was in the tracker's last pass.
/// noted one pass earlier was in the tracker's last pass.
now: u32,
/// The players the level's tracker runs for (`Hub::watches`: their
/// index), where they are horizontally, and their view distance in blocks:
/// the clients that could pair.
/// the clients that could pair. Sorted by x, so an entity looks only at
/// the ones within its range along x.
viewers: Vec<(usize, [f64; 2], i32)>,
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
percent: i32,
/// The last type asked about and its scaled tracking range: the
/// entities of a type come in runs (a farm, a pile of items).
last_kind: Option<(String, i32)>,
}
impl IdleStates {
@@ -51,16 +78,46 @@ impl IdleStates {
.map(|(i, g)| (i, [g.position[0], g.position[2]], g.view_distance * 16))
.collect();
let kept = self.levels.entry(hub.config.dimension.clone()).or_default();
Some(IdlePass {
kept,
now,
viewers,
percent,
})
Some(IdlePass::new(kept, now, viewers, percent))
}
}
impl IdlePass<'_> {
impl<'a> IdlePass<'a> {
fn new(
kept: &'a mut Kept,
now: u32,
mut viewers: Vec<(usize, [f64; 2], i32)>,
percent: i32,
) -> Self {
// A player somewhere not finite is within no range: the distance
// test below fails for it.
viewers.retain(|(_, [x, z], _)| x.is_finite() && z.is_finite());
viewers.sort_by(|a, b| a.1[0].total_cmp(&b.1[0]));
kept.next_ids.clear();
kept.next_places.clear();
IdlePass {
last: kept.pass.wrapping_add(1) == now,
kept,
cursor: 0,
now,
viewers,
percent,
last_kind: None,
}
}
/// The type's tracking range in blocks, scaled as the tracker scales it.
fn range(&mut self, kind: &str) -> i32 {
match &mut self.last_kind {
Some((last, range)) if last.as_str() == kind => *range,
last => {
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
*last = Some((kind.to_owned(), range));
range
}
}
}
/// Whether the idle entity `id` of type `kind`, now at `at`, can be
/// described bare: it was in the tracker's last pass at the same place,
/// carries no passengers (whose ranges widen its own), and every client
@@ -70,6 +127,16 @@ impl IdlePass<'_> {
&& hub.riding.passengers(id).is_empty()
}
/// The place the last pass noted for `id`, if it is found where the
/// pass's order says it would be.
fn noted(&mut self, id: i32) -> Option<Place> {
let ids = &self.kept.ids;
let end = ids.len().min(self.cursor + LOOK_AHEAD);
let k = (self.cursor..end).find(|&k| ids[k] == id)?;
self.cursor = k + 1;
Some(self.kept.places[k])
}
/// [`Self::bare`] but for the passengers, with whether the player `i`
/// tracks the entity.
fn unchanged(
@@ -79,27 +146,36 @@ impl IdlePass<'_> {
at: &Place,
tracks: impl Fn(usize) -> bool,
) -> bool {
// A new one reads as noted two passes ago.
let now = self.now;
let noted = self.kept.entry(id).or_insert((now.wrapping_sub(2), *at));
let still = noted.0.wrapping_add(1) == now && noted.1 == *at;
*noted = (now, *at);
if !still {
let noted = if self.last { self.noted(id) } else { None };
self.kept.next_ids.push(id);
self.kept.next_places.push(*at);
if noted != Some(*at) {
return false;
}
// The tracker's `sees`, but for the chunk it also asks the client to have.
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
!self.viewers.iter().any(|&(i, [x, z], view)| {
let range = f64::from(range.min(view));
let (dx, dz) = (x - at.position[0], z - at.position[2]);
dx * dx + dz * dz <= range * range && !tracks(i)
})
let range = self.range(kind);
// Only a client within `range` along x can be within it: a block
// more spares the rounding of the squares.
let reach = f64::from(range) + 1.0;
let x = at.position[0];
let from = self.viewers.partition_point(|v| v.1[0] < x - reach);
!self.viewers[from..]
.iter()
.take_while(|v| v.1[0] <= x + reach)
.any(|&(i, [vx, vz], view)| {
let range = f64::from(range.min(view));
let (dx, dz) = (vx - at.position[0], vz - at.position[2]);
dx * dx + dz * dz <= range * range && !tracks(i)
})
}
/// Forget the entities this pass did not note: they left, or tick again.
/// The notes of this pass replace the last one's: the entities it did not
/// note left, or tick again.
pub(super) fn finish(self) {
let now = self.now;
self.kept.retain(|_, (stamp, _)| *stamp == now);
let kept = self.kept;
std::mem::swap(&mut kept.ids, &mut kept.next_ids);
std::mem::swap(&mut kept.places, &mut kept.next_places);
kept.pass = self.now;
}
}
@@ -116,14 +192,22 @@ mod tests {
}
}
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
/// One pass `now` over `entities` (id, type, place), each answered with
/// whether the player `i` tracks it; what each could be described as.
fn pass(
kept: &mut Kept,
now: u32,
entities: &[(i32, &str, Place)],
tracks: impl Fn(i32, usize) -> bool,
) -> Vec<bool> {
// One client at the origin, one 400 blocks away; both see 10 chunks.
IdlePass {
kept,
now,
viewers: vec![(0, [0.0, 0.0], 160), (1, [400.0, 0.0], 160)],
percent: 100,
}
let viewers = vec![(1, [400.0, 0.0], 160), (0, [0.0, 0.0], 160)];
let mut pass = IdlePass::new(kept, now, viewers, 100);
let bare = (entities.iter())
.map(|(id, kind, at)| pass.unchanged(*id, kind, at, |i| tracks(*id, i)))
.collect();
pass.finish();
bare
}
/// An idle cow (tracked 10 chunks away) is described bare while it stays
@@ -131,24 +215,54 @@ mod tests {
/// there could pair with it, when it moved, and after a pass it was not in.
#[test]
fn an_idle_entity_is_bare_until_a_client_could_pair_or_it_moves() {
let mut kept = FxHashMap::default();
let cow = "minecraft:cow";
let mut kept = Kept::default();
let cow = |at| [(7, "minecraft:cow", at)];
let at = place(100.0);
// Its first pass describes it in full.
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
assert_eq!(pass(&mut kept, 5, &cow(at), |_, _| true), [false]);
// Tracked by the client in range; the far one can't pair.
let tracked = |i| i == 0;
assert!(pass(&mut kept, 6).unchanged(7, cow, &at, tracked));
let tracked = |_, i| i == 0;
assert_eq!(pass(&mut kept, 6, &cow(at), tracked), [true]);
// The client in range doesn't track it yet: it could pair.
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
assert_eq!(pass(&mut kept, 7, &cow(at), |_, _| false), [false]);
// 100 blocks is past an item's 6 chunks: no client could pair.
assert!(pass(&mut kept, 8).unchanged(7, "minecraft:item", &at, |_| false));
let item = [(7, "minecraft:item", at)];
assert_eq!(pass(&mut kept, 8, &item, |_, _| false), [true]);
// It moved.
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
assert_eq!(pass(&mut kept, 9, &cow(place(100.5)), tracked), [false]);
// A pass went by without it.
assert!(!pass(&mut kept, 11).unchanged(7, cow, &place(100.5), tracked));
// Only what a pass noted stays.
pass(&mut kept, 12).finish();
assert!(kept.is_empty());
assert_eq!(pass(&mut kept, 11, &cow(place(100.5)), tracked), [false]);
// The client 400 blocks away is in range of a cow next to it.
let far = [(7, "minecraft:cow", place(300.0))];
pass(&mut kept, 12, &far, |_, _| true);
assert_eq!(pass(&mut kept, 13, &far, |_, i| i == 0), [false]);
assert_eq!(pass(&mut kept, 14, &far, |_, i| i == 1), [true]);
}
/// Each entity's note is found in the last pass's order, past the ones
/// that left since and after one that joined.
#[test]
fn notes_are_found_in_the_last_pass_order() {
let mut kept = Kept::default();
let item = |id| (id, "minecraft:item", place(1000.0 + f64::from(id)));
let all = |ids: &[i32]| ids.iter().map(|&id| item(id)).collect::<Vec<_>>();
let first = all(&(1..=20).collect::<Vec<_>>());
pass(&mut kept, 1, &first, |_, _| false);
// 2 to 4 left, 30 joined in the middle, 12 to 14 left.
let ids = [1, 5, 6, 30, 7, 8, 9, 10, 11, 15, 16, 17, 18, 19, 20];
let bare = pass(&mut kept, 2, &all(&ids), |_, _| false);
let full: Vec<i32> = ids
.iter()
.zip(&bare)
.filter(|(_, b)| !**b)
.map(|(id, _)| *id)
.collect();
assert_eq!(full, [30]);
// One that moved out of order beyond the look-ahead is described in
// full, which is always right, and the next pass finds it again.
let moved = [20, 1, 5, 6, 7];
let bare = pass(&mut kept, 3, &all(&moved), |_, _| false);
assert_eq!(bare, [false, true, true, true, true]);
assert_eq!(pass(&mut kept, 4, &all(&moved), |_, _| false), [true; 5]);
}
}
@@ -1732,6 +1732,7 @@ mod item_column_tests {
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
random: horizon_core::random::LegacyRandom::new(0),
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -1747,6 +1748,7 @@ mod item_column_tests {
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
fall_distance: 0.0,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
@@ -731,8 +731,15 @@ mod tests {
use super::*;
#[test]
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES; run alone to isolate the mod registry"]
#[ignore = "prepared resources in HORIZON_TEST_RESOURCES"]
fn the_standard_helpers_route_to_native_owned_storage() {
crate::mod_api::in_own_process(
"game::machines::native_fluids::tests::the_standard_helpers_route_to_native_owned_storage",
the_standard_helpers_route_to_native_owned_storage_in_own_process,
);
}
fn the_standard_helpers_route_to_native_owned_storage_in_own_process() {
use crate::game::{Audio, Options, Progress, Settings, World, load_resources};
use crate::mod_api::{
self, CustomBlockFluidsEvent, CustomFluidDrainEvent, CustomFluidFillEvent,
+15
View File
@@ -1334,6 +1334,21 @@ impl Game<'_> {
);
}
}
// `LivingEntity.tick` ticks `EntityEquipment` (the armour slots) as
// well as the inventory, so a compass worn there loses its target too.
let gone = self.armor.each_ref().map(|stack| {
stack
.as_ref()
.is_some_and(|s| lodestone_target_gone(self, s, dim))
});
for (stack, gone) in self.armor.iter_mut().zip(gone) {
if gone && let Some(stack) = stack {
stack.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(Tag::Compound(Default::default())),
);
}
}
}
fn give_or_drop_stack(&mut self, stack: ItemStack) {
if let Some(rest) = self.give(stack)
@@ -24,8 +24,15 @@ impl Hooks for Arc<Probe> {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mod_toast_queues_unless_a_mod_cancels_it() {
crate::mod_api::in_own_process(
"game::advancements::mod_toast_tests::a_mod_toast_queues_unless_a_mod_cancels_it",
a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process,
);
}
fn a_mod_toast_queues_unless_a_mod_cancels_it_in_own_process() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
+10 -4
View File
@@ -969,10 +969,16 @@ mod tests {
assert_eq!(verdict(&ruby, &manifest, true), switches_on_restart);
assert_eq!(verdict(&ruby, &manifest, false), Some(Ok(())));
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::File::open(&ruby)
.unwrap()
.set_modified(later)
.unwrap();
let mut folder = std::fs::OpenOptions::new();
folder.read(true);
// Windows opens a folder only with FILE_FLAG_BACKUP_SEMANTICS, and sets its time only
// with write access.
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
folder.write(true).custom_flags(0x0200_0000);
}
folder.open(&ruby).unwrap().set_modified(later).unwrap();
assert_eq!(verdict(&ruby, &manifest, false), switches_on_restart);
std::fs::remove_dir_all(&dir).unwrap();
}
+4 -4
View File
@@ -655,13 +655,13 @@ impl Game<'_> {
/// A saved dropped item.
pub(super) fn load_item(&mut self, tag: Tag, uuid: Option<[i32; 4]>) {
self.next_entity += 1;
// `ItemEntity(EntityType, Level)` before the load: the item's own random
// makes its UUID (kept unless the tag has one) and bobOffs, not the level's.
// Its yaw, the next draw, is always replaced by the saved rotation.
// `ItemEntity(EntityType, Level)` first: its own random draws UUID, bobOffs and yaw.
let mut own = random_source::entity();
let fresh = horizon_client::save::insecure_uuid(&mut own);
let bob = own.next_float() * std::f32::consts::PI * 2.0;
if let Some(e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
own.next_float();
if let Some(mut e) = entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob) {
e.random = own;
self.remember(e.id, Some(uuid.unwrap_or(fresh)), tag);
self.dropped.push(e);
}
@@ -1,5 +1,5 @@
//! Needs private prepared resources: installs process-wide trait mods, so it runs in a process of its
//! own (`in_own_process`) and passes whatever else runs beside it.
//! own (`mod_api::in_own_process`) and passes whatever else runs beside it.
use super::super::*;
use crate::mod_api::{
self, Hooks, Host, NativeRecipeCategory, NativeRecipeLookup, NativeStack,
@@ -45,36 +45,11 @@ fn stack(item: &str) -> NativeStack {
}
}
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
/// for just this test (`--exact`, `--ignored`) and requires that exactly one test ran and passed; the
/// child sees `OWN_PROCESS` and runs `body` itself.
fn in_own_process(name: &str, body: impl FnOnce()) {
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
return body();
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args([name, "--exact", "--ignored", "--test-threads=1"])
.env(OWN_PROCESS, name)
.output()
.unwrap();
let report = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && report.contains("test result: ok. 1 passed;"),
"{name} failed in its own process:\n{report}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them() {
const NAME: &str = "game::native_recipes::recipe_viewer_tests::lookups_wait_for_the_next_frame_and_stop_at_the_viewer_that_takes_them";
in_own_process(NAME, lookups_wait_for_the_next_frame);
mod_api::in_own_process(NAME, lookups_wait_for_the_next_frame);
}
fn lookups_wait_for_the_next_frame() {
+4 -25
View File
@@ -6256,32 +6256,11 @@ impl Game<'_> {
};
let at = Vec3::new(p[0], p[1], p[2]);
// ItemEntity's constructor draws bobOffs from the item's own random.
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
* std::f32::consts::PI
* 2.0;
let mut random = horizon_client::mobs::entity_random(id as u32, at);
let bob_offset = random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
previous: at,
velocity,
on_ground: false,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
was_touching_water: false,
random,
..ItemEntity::new(id as u32, stack, at, velocity, bob_offset)
});
}
/// `Entity.lerpMotion` (and `recreateFromPacket`'s motion) for the
@@ -167,6 +167,7 @@ impl Game<'_> {
|| self.settings.extras.pvp.tweaks.clear_lava,
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
};
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
if !self.settings.horizon.fog {
@@ -294,6 +294,7 @@ fn pack_particles(
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_dyes::set(&colors);
horizon_client::pack_palette::set(&colors);
horizon_client::brewing::set_pack_colors(colors.effects());
colors
}
+27 -10
View File
@@ -952,7 +952,7 @@ pub struct Entities {
/// moves. One the entities' own ticks added waits for the next pass, as
/// vanilla's waits for the next tick.
pub synced: Vec<(i32, i32)>,
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
states: slab::ServerEntities,
/// The level tick, stamped on every entity still in the level.
tick: u32,
/// This tick's entities as the range checks read them, the chunks they
@@ -1072,7 +1072,7 @@ impl Entities {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
self.states.len() == 0
}
/// How many passes the level's tracking has run.
@@ -1160,12 +1160,29 @@ impl Entities {
self.quiet.resize(entities.len(), false);
// The states stamped this tick: when that is all of them, none left.
let mut stamped = 0;
self.states.begin();
for (index, e) in entities.iter().enumerate() {
let mut added = false;
let s = self.states.entry(e.id).or_insert_with(|| {
added = true;
ServerEntity::new(e, kind(&e.kind), riding)
});
let s = match self.states.meet(e.id) {
Some((slot, by_id)) => {
let s = self.states.at(slot);
// Found out of order: the next one is looked for after
// where this one was in the last tick's order.
if by_id && s.seen == now.wrapping_sub(1) {
let last = s.index;
self.states.resume_after(last);
}
self.states.at_mut(slot)
}
None => {
added = true;
let slot = self
.states
.insert(e.id, ServerEntity::new(e, kind(&e.kind), riding));
self.states.met_new(e.id, slot);
self.states.at_mut(slot)
}
};
if s.seen != now {
stamped += 1;
}
@@ -1217,6 +1234,7 @@ impl Entities {
hidden: e.hidden,
});
}
self.states.end();
// TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its
// widest indirect passenger, so a rider never leaves before its mount.
for (&vehicle, passengers) in &riding.passengers {
@@ -1390,9 +1408,7 @@ impl Entities {
if self.quiet[index] && !self.pairs[index] {
continue;
}
let Some(s) = self.states.get_mut(&e.id) else {
continue;
};
let s = self.states.at_mut(self.states.slot_of_index(index));
self.packets.clear();
// Paired as it joined, then its first changes go to everyone
// tracking it, those just paired with it included. Arrows wait for the
@@ -1466,13 +1482,14 @@ impl Entities {
}
// ChunkMap.removeEntity for the ones no longer in the level.
if self.states.len() > stamped {
self.states.retain(|_, s| s.seen == now);
self.states.retain(|s| s.seen == now);
}
}
}
#[cfg(test)]
mod late_pair_tests;
mod slab;
#[cfg(test)]
mod tests {
@@ -42,7 +42,7 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
let mut e = trident();
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
assert_eq!(level.tick, 1);
assert_eq!(level.states[&37].tick, 0);
assert_eq!(level.states.get(&37).unwrap().tick, 0);
assert!(viewers[0].tracker.is_tracking(37));
assert_eq!(
viewers[0]
@@ -57,14 +57,14 @@ fn late_pairing_does_not_duplicate_or_spend_the_next_arrow_update() {
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| Some(0));
assert!(viewers[0].out.is_empty());
assert!(viewers[0].tracker.take_events().is_empty());
assert_eq!(level.states[&37].tick, 0);
assert_eq!(level.states.get(&37).unwrap().tick, 0);
// It hits a block between its introduction and the next tracking pass.
e.position[0] += 0.25;
e.velocity = [0.0; 3];
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
level.tick(&[&e], &riding, &mut viewers, |_| Some(0));
assert_eq!(level.tick, 2);
assert_eq!(level.states[&37].tick, 1);
assert_eq!(level.states.get(&37).unwrap().tick, 1);
assert!(
!viewers[0]
.out
@@ -0,0 +1,194 @@
//! A level's `ServerEntity`s by id (`ChunkMap.entityMap`), found without hashing on most ticks.
//!
//! A level tracks tens of thousands of entities, most of them items and mobs frozen past the
//! simulation distance, and looked each one up by id on every tick: one probe of a large table
//! for each, missing the cache nearly every time. The game describes its entities in the same
//! order every tick (its lists keep their order), so each tick notes the order it met them in,
//! and the next one finds each entity at or just past where it found the one before. Only an
//! entity that joined, or one met out of order, is looked up by id.
use super::ServerEntity;
use crate::fast_hash::FxHashMap;
/// How many entries past the last one found an entity is looked for: the entities that left
/// the level since are skipped over.
const LOOK_AHEAD: usize = 4;
#[derive(Default)]
pub(super) struct ServerEntities {
/// The entities and their ids; None in a slot that is free.
slots: Vec<Option<(i32, ServerEntity)>>,
free: Vec<u32>,
by_id: FxHashMap<i32, u32>,
/// The last tick's entities in the order it met them (id, slot), this tick's so far, and
/// where the next one is looked for.
order: Vec<(i32, u32)>,
next_order: Vec<(i32, u32)>,
cursor: usize,
}
impl ServerEntities {
pub(super) fn len(&self) -> usize {
self.by_id.len()
}
pub(super) fn contains_key(&self, id: &i32) -> bool {
self.by_id.contains_key(id)
}
pub(super) fn get(&self, id: &i32) -> Option<&ServerEntity> {
self.by_id.get(id).map(|&slot| self.at(slot))
}
pub(super) fn at(&self, slot: u32) -> &ServerEntity {
&self.slots[slot as usize].as_ref().expect("a live slot").1
}
pub(super) fn at_mut(&mut self, slot: u32) -> &mut ServerEntity {
&mut self.slots[slot as usize].as_mut().expect("a live slot").1
}
/// Adds the entity `id` (not tracked yet), returning its slot.
pub(super) fn insert(&mut self, id: i32, s: ServerEntity) -> u32 {
debug_assert!(!self.by_id.contains_key(&id));
let slot = match self.free.pop() {
Some(slot) => {
self.slots[slot as usize] = Some((id, s));
slot
}
None => {
self.slots.push(Some((id, s)));
(self.slots.len() - 1) as u32
}
};
self.by_id.insert(id, slot);
slot
}
/// Starts meeting a tick's entities in order.
pub(super) fn begin(&mut self) {
self.cursor = 0;
self.next_order.clear();
}
/// The slot of the entity `id`, met next in this tick's order, and whether it was found by
/// id (not where the last tick's order had it). Notes it in this tick's order.
pub(super) fn meet(&mut self, id: i32) -> Option<(u32, bool)> {
let end = self.order.len().min(self.cursor + LOOK_AHEAD);
let found = match (self.cursor..end).find(|&k| self.order[k].0 == id && self.holds(k, id)) {
Some(k) => {
self.cursor = k + 1;
Some((self.order[k].1, false))
}
None => self.by_id.get(&id).map(|&slot| (slot, true)),
};
if let Some((slot, _)) = found {
self.next_order.push((id, slot));
}
found
}
/// Whether the last tick's `k`-th slot still holds the entity `id`.
fn holds(&self, k: usize, id: i32) -> bool {
(self.slots[self.order[k].1 as usize].as_ref()).is_some_and(|(held, _)| *held == id)
}
/// Notes an entity met this tick that was not tracked before it.
pub(super) fn met_new(&mut self, id: i32, slot: u32) {
self.next_order.push((id, slot));
}
/// The next entity is looked for after the last tick's `index`-th: where the one just
/// found by id was then.
pub(super) fn resume_after(&mut self, index: usize) {
self.cursor = index + 1;
}
/// This tick's order is the one the next tick looks in; the slot of the tick's
/// `index`-th entity, until then.
pub(super) fn end(&mut self) {
std::mem::swap(&mut self.order, &mut self.next_order);
}
pub(super) fn slot_of_index(&self, index: usize) -> u32 {
self.order[index].1
}
/// Keeps only the entities `keep` says to.
pub(super) fn retain(&mut self, mut keep: impl FnMut(&ServerEntity) -> bool) {
for (slot, entry) in self.slots.iter_mut().enumerate() {
if let Some((id, s)) = entry
&& !keep(s)
{
self.by_id.remove(id);
*entry = None;
self.free.push(slot as u32);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::{EntityState, Riding};
use super::*;
fn entity(id: i32) -> ServerEntity {
let e = EntityState {
id,
kind: "minecraft:item".into(),
..Default::default()
};
ServerEntity::new(&e, Some(1), &Riding::default())
}
/// One tick meeting `ids` in order: each one's slot and whether it was
/// found by id; the new ones are added.
fn tick(all: &mut ServerEntities, ids: &[i32]) -> Vec<(u32, bool)> {
all.begin();
let met = (ids.iter())
.map(|&id| {
all.meet(id).unwrap_or_else(|| {
let slot = all.insert(id, entity(id));
all.met_new(id, slot);
(slot, true)
})
})
.collect();
all.end();
met
}
/// The entities met in last tick's order are found there, past the ones
/// that left; the rest by id, each in its own slot, freed ones reused.
#[test]
fn entities_are_found_in_last_ticks_order_or_by_id() {
let mut all = ServerEntities::default();
let first = tick(&mut all, &[10, 11, 12, 13, 14, 15]);
assert_eq!(
first.iter().map(|m| m.0).collect::<Vec<_>>(),
[0, 1, 2, 3, 4, 5]
);
// 11 and 12 left; 20 joined between 13 and 14.
let met = tick(&mut all, &[10, 13, 20, 14, 15]);
assert_eq!(
met,
[(0, false), (3, false), (6, true), (4, false), (5, false)]
);
// 15 out of order is found by id; the next ones in order again.
let met = tick(&mut all, &[15, 10, 20]);
assert_eq!(met, [(5, true), (0, false), (6, false)]);
// Only those three are left in the level: the others' slots go to
// the next ones to join.
for (slot, _) in &met {
all.at_mut(*slot).seen = 1;
}
all.retain(|s| s.seen == 1);
assert_eq!(all.len(), 3);
assert!(!all.contains_key(&13) && all.get(&20).is_some());
let met = tick(&mut all, &[15, 10, 20, 30, 31, 32]);
let slots: Vec<u32> = met.iter().map(|m| m.0).collect();
assert_eq!(slots, [5, 0, 6, 4, 3, 2]);
assert_eq!(all.slot_of_index(4), 3);
}
}
+1 -1
View File
@@ -3318,7 +3318,7 @@ impl Hub {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.color as i32;
let argb = (alpha << 24) | def.shown_color() as i32;
("entity_effect", Some(ParticleData::Color(argb)))
}
};
+1 -1
View File
@@ -1173,7 +1173,7 @@ pub struct Extra {
pub sitting: bool,
/// `Parrot.partyParrot` and `jukebox`: the playing jukebox it dances to.
pub party_jukebox: Option<[i32; 3]>,
/// The owner's UUID (tamed animals).
/// The owner's UUID: a tamed animal's, or the reference a vex was loaded with.
pub owner: Option<[i32; 4]>,
/// `Vindicator.isJohnny`: a custom name that was ever `Johnny`, which
/// `setCustomName` latches and nothing clears.
+2 -10
View File
@@ -510,9 +510,6 @@ pub fn base_value(m: &Mob, a: Attr) -> f64 {
/// [`base_value`] with the type's default given (`default_value`), for a
/// caller that already looked it up for the mob's type.
pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>) -> f64 {
if a == Attr::MovementSpeed && m.species() == Species::Ravager {
return m.attrs.speed;
}
m.extra
.attributes
.as_ref()
@@ -532,8 +529,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
/// growing up) is saved apart; these are the rest:
/// - `movement_speed`: Zombie and Piglin.setBaby (the load calls it even for an
/// adult), `Enderman.setTarget` (the load ends in `readPersistentAngerSaveData`,
/// which calls it), a Ravager whose load leaves it immobile, and the first tick of
/// any mob (`LivingEntity.removeFrost`).
/// which calls it), and the first tick of any mob (`LivingEntity.removeFrost`).
/// - `max_health`: `Wolf.applyTamingSideEffects`, through `setTame(false, true)`
/// when the load finds no owner (a wolf that is tamed in game makes it too).
/// - `attack_damage`: `Rabbit.setVariant`, and a baby Goat or Hoglin, whose load's
@@ -541,11 +537,7 @@ pub fn base_value_with(m: &Mob, a: Attr, default: impl FnOnce() -> Option<f64>)
pub fn made_at_load(m: &Mob) -> Vec<Attr> {
let s = m.species();
let mut made = Vec::new();
if super::doors::is_zombie(s)
|| matches!(s, Species::Piglin | Species::Enderman)
|| (s == Species::Ravager && super::ravager::immobile(m))
|| m.age > 0
{
if super::doors::is_zombie(s) || matches!(s, Species::Piglin | Species::Enderman) || m.age > 0 {
made.push(Attr::MovementSpeed);
}
if s == Species::Wolf {
+7 -9
View File
@@ -95,18 +95,16 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() {
return;
}
let max = if immobile(m) {
0.0
} else if m.ai.target.is_some() {
0.35
} else {
0.3
};
m.attrs.speed = if max == 0.0 {
// The speed lives in the attribute's base, which the save writes and the
// load restores (AttributeMap.pack and apply).
let speed = if immobile(m) {
0.0
} else {
m.attrs.speed + (max - m.attrs.speed) * 0.1
let max = if m.ai.target.is_some() { 0.35 } else { 0.3 };
let base = super::finalize::base_value(m, super::finalize::Attr::MovementSpeed);
base + (max - base) * 0.1
};
super::finalize::set_base(m, super::finalize::Attr::MovementSpeed, speed);
if m.horizontal_collision && w.mob_griefing {
let b = m
.bounds()
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn armor_tint(undyed: Option<u32>, stack: Option<&crate::items::ItemStack>)
.unwrap_or(undyed);
abgr(dyed)
}
pub use crate::pack_dyes::{diffuse as dye, sheep_wool as sheep_wool_rgb};
pub use crate::pack_palette::{diffuse as dye, sheep_wool as sheep_wool_rgb};
/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, ageInTicks)`: the 16
/// wool colours in DyeColor order, one per 25 ticks, lerped in between. The
/// colour a `jeb_` sheep's wool takes.
+168 -9
View File
@@ -143,6 +143,21 @@ impl Goal for RandomMove {
}
}
}
/// `Vex.getOwner`: the mob the vex is bound to. `counter2` holds its id while
/// the evoker that summoned it is in the level; a vex loaded from a save knows
/// only the UUID it was saved with, and vanilla's `EntityReference.getEntity`
/// asks the level for that entity every time it is called
/// (`MobWorld::who_by_uuid`), so an owner that loaded later is found too.
pub fn owner(m: &Mob, lookup: Option<&dyn super::WhoLookup>) -> Option<u32> {
if m.extra.counter2 != 0 {
return Some(m.extra.counter2 as u32);
}
match lookup?.who_by_uuid(m.extra.owner?)? {
Who::Mob(id) => Some(id),
// `Vex.setOwner` takes a `Mob`: a UUID naming the player is no owner.
Who::Player => None,
}
}
/// `VexCopyOwnerTargetGoal`: the evoker's target.
#[derive(Clone, Debug)]
struct CopyOwnerTarget;
@@ -152,26 +167,24 @@ impl Goal for CopyOwnerTarget {
super::goals::TARGET
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let owner = m.extra.counter2 as u32;
owner != 0
&& cx
.w
.others
let seen = owner(m, cx.w.who_lookup).and_then(|owner| {
cx.w.others
.iter()
.find(|s| s.id == owner)
.and_then(|s| s.target)
.is_some_and(|t| t != Who::Mob(m.id))
});
seen.is_some_and(|t| t != Who::Mob(m.id))
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.target_info(cx.w).is_some_and(|t| t.alive)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
let owner = m.extra.counter2 as u32;
m.ai.target =
m.ai.target = owner(m, cx.w.who_lookup).and_then(|owner| {
cx.w.others
.iter()
.find(|s| s.id == owner)
.and_then(|s| s.target);
.and_then(|s| s.target)
});
}
}
/// `VexMoveControl`: accelerate straight at the wanted point.
@@ -275,3 +288,149 @@ pub fn texture(m: &Mob) -> &'static str {
"entity/illager/vex"
}
}
/// `Vex.addAdditionalSaveData`: the bound origin, the limited life and the
/// owner reference's UUID (`EntityReference.store`, left out when there is no
/// owner). The level answers for an owner still in it; a reference the vex was
/// loaded with stands when the owner is gone.
pub fn write_nbt(
m: &Mob,
map: &mut horizon_world::nbt::Compound,
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
) {
use horizon_world::nbt::Tag;
if let Some(h) = m.extra.home {
map.insert("bound_pos".into(), Tag::IntArray(h.to_vec()));
}
if m.extra.egg_time > 0 {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
let resolved = if m.extra.counter2 != 0 {
crate::entity_nbt::who_uuid(Who::Mob(m.extra.counter2 as u32), uuid_of)
} else {
None
};
match resolved.or(m.extra.owner) {
Some(uuid) => {
map.insert("owner".into(), Tag::IntArray(uuid.to_vec()));
}
None => {
map.remove(&"owner".into());
}
}
}
/// `Vex.readAdditionalSaveData`: the bound origin, the life left (-1 when
/// absent) and the owner's UUID, kept as the UUID it is - an id would name
/// whatever mob has it now - and resolved through the level when the vex asks
/// for its owner (`owner`).
pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
use horizon_world::nbt::Tag;
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
{
m.extra.home = Some([v[0], v[1], v[2]]);
}
m.extra.egg_time = crate::save::number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
m.extra.owner = crate::save::uuid_from_tag(tag.get("owner"));
}
#[cfg(test)]
mod owner_save_tests {
use super::*;
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
use crate::mobs::WhoLookup;
use horizon_world::nbt::Tag;
use std::collections::HashMap;
use std::sync::Arc;
/// A vex with the id `EVOKER` in the level: what `Vex.setOwner` records
/// when an evoker summons it.
const EVOKER: u32 = 7;
fn vex() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:vex".into();
ty.species = Species::Vex;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `Level.getEntity(uuid)` for the test: `Game` answers this with
/// `Game::who_by_uuid`.
struct Level(HashMap<[i32; 4], Who>);
impl WhoLookup for Level {
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who> {
self.0.get(&uuid).copied()
}
}
/// `Vex.addAdditionalSaveData` stores its owner reference's UUID
/// (`EntityReference.store`, nothing at all without an owner) and
/// `readAdditionalSaveData` reads it back (`EntityReference.read`), so a
/// summoned vex keeps the evoker whose target it copies across a chunk
/// save. Horizon models the owner as the owner's mob id and wrote neither,
/// so a reloaded vex had no owner to copy a target from.
#[test]
fn a_vexs_owner_uuid_survives_the_chunk_save() {
let evoker_uuid = [3, 1, 4, 1];
let mut summoned = vex();
assert_eq!(owner(&summoned, None), None, "a vex nobody summoned");
summoned.extra.counter2 = EVOKER as i32;
assert_eq!(owner(&summoned, None), Some(EVOKER), "the evoker's id");
let uuid_of = |id: u32| (id == EVOKER).then_some(evoker_uuid);
let saved = mob_to_nbt(&summoned, [0; 4], None, None, uuid_of);
assert_eq!(
saved.get("owner"),
Some(&Tag::IntArray(evoker_uuid.to_vec()))
);
// The loaded vex knows the UUID: no id survives a save, and its owner
// is found again only through the level (`EntityReference.getEntity`).
let types = [("minecraft:vex".into(), summoned.kind.clone())].into();
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(back.extra.owner, Some(evoker_uuid));
assert_eq!(back.extra.counter2, 0);
let level = Level(HashMap::from([(evoker_uuid, Who::Mob(42))]));
assert_eq!(owner(&back, Some(&level)), Some(42), "the evoker, found");
// An owner the level does not have, and a UUID naming the player, are
// no owner: `Vex.setOwner` takes a `Mob`.
assert_eq!(owner(&back, Some(&Level(HashMap::new()))), None);
let player = Level(HashMap::from([(evoker_uuid, Who::Player)]));
assert_eq!(owner(&back, Some(&player)), None);
assert_eq!(owner(&back, None), None, "a level with no UUIDs");
// A save read again keeps the reference even when nothing in the level
// answers for it, as `EntityReference` does.
let again = mob_to_nbt(&back, [0; 4], None, None, |_| None);
assert_eq!(
again.get("owner"),
Some(&Tag::IntArray(evoker_uuid.to_vec()))
);
}
/// `EntityReference.store` writes nothing for a vex with no owner, and a
/// save that names none loads with none - neither the `owner` key a loaded
/// compound carried nor an id left over may stand.
#[test]
fn a_vex_with_no_owner_saves_no_owner_key() {
let mut loose = vex();
let stale = Tag::compound([
("id", Tag::String("minecraft:vex".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.5_f64.to_bits()),
Tag::Double(64.0_f64.to_bits()),
Tag::Double(0.5_f64.to_bits()),
]),
),
("owner", Tag::IntArray(vec![9, 9, 9, 9])),
]);
let types = [("minecraft:vex".into(), loose.kind.clone())].into();
let loaded = mob_from_nbt(&stale, &types, 3, None).unwrap();
assert_eq!(loaded.extra.owner, Some([9, 9, 9, 9]));
loose.extra.owner = None;
loose.extra.counter2 = 0;
let saved = mob_to_nbt(&loose, [0; 4], Some(&stale), None, |_| None);
assert_eq!(saved.get("owner"), None, "no owner, no key");
}
}
+33
View File
@@ -1221,6 +1221,32 @@ pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
/// Runs `body` in a fresh process of this test binary: `mod_api::MODS` is set once per process, and
/// another test that set it first would make this one's `set` fail. The parent starts the binary again
/// for just this test (`--exact`, `--include-ignored`) and requires that exactly one test ran and passed; the
/// child sees `OWN_PROCESS` and runs `body` itself.
#[cfg(test)]
pub(crate) fn in_own_process(name: &str, body: impl FnOnce()) {
const OWN_PROCESS: &str = "HORIZON_TEST_OWN_PROCESS";
if std::env::var_os(OWN_PROCESS).is_some_and(|v| v == name) {
return body();
}
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args([name, "--exact", "--include-ignored", "--test-threads=1"])
.env(OWN_PROCESS, name)
.output()
.unwrap();
let report = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && report.contains("test result: ok. 1 passed;"),
"{name} failed in its own process:\n{report}"
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1233,6 +1259,13 @@ mod tests {
/// turned on; a test's own list is never switched.
#[test]
fn a_mod_turned_off_is_skipped_until_turned_on() {
crate::mod_api::in_own_process(
"mod_api::tests::a_mod_turned_off_is_skipped_until_turned_on",
a_mod_turned_off_is_skipped_until_turned_on_in_own_process,
);
}
fn a_mod_turned_off_is_skipped_until_turned_on_in_own_process() {
name_mods(&["quiet", "broken"]);
let _ = MODS.set(Arc::from(vec![
Box::new(Quiet) as Box<dyn Hooks>,
+5 -1
View File
@@ -255,6 +255,9 @@ pub struct Projectile {
pub air_supply: i32,
/// `Entity.hasVisualFire`: an optional saved flag independent of burning.
pub has_visual_fire: bool,
/// `Entity.fallDistance` (`fall_distance` in its save): a projectile sets
/// its position without `Entity.move`, so it keeps what it was given.
pub fall_distance: f64,
/// `Entity.DATA_SHARED_FLAGS_ID`'s fire bit, set by the server's base tick.
pub synced_fire: bool,
/// `Fireball.DATA_ITEM_STACK`: the item a fireball is drawn as, a fire
@@ -473,6 +476,7 @@ impl Projectile {
fire_ticks: 0,
air_supply: 300,
has_visual_fire: false,
fall_distance: 0.0,
synced_fire: false,
item,
arrow: kind_is_arrow.then(|| {
@@ -675,7 +679,7 @@ impl Projectile {
return -1;
}
let rgb = match (effect, &self.arrow) {
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.color),
(Some((id, _)), _) => crate::brewing::effect(id).map(|e| e.shown_color()),
(None, Some(a)) if &*a.pickup_item.item == "minecraft:tipped_arrow" => a
.pickup_item
.potion()
+9 -1
View File
@@ -493,11 +493,19 @@ dependencies = [
]
[[package]]
name = "horizon-shader-api"
name = "horizon-render-pipeline"
version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
]
[[package]]
name = "horizon-shader-api"
version = "0.1.0"
dependencies = [
"horizon-render-pipeline",
"naga",
"png",
"serde",
"serde_json",
+166
View File
@@ -8,10 +8,41 @@ use std::path::Path;
use serde_json::Value as Json;
/// The `environment` fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// `powder_snow_fog`: the fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// `blindness_fog_distance`, `darkness_fog_distance`, `boss_fog_distance`: the fog ends of
/// Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// `water_fog_brightening`: how much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// `water_fog_brightening_time`: the ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
}
}
}
/// Every pack's custom colours, by dotted key.
#[derive(Clone, Debug, Default)]
pub struct PackColors {
entries: BTreeMap<String, Json>,
/// [`Self::environment`], read once with the packs: the fog reads it every frame.
pub env: Environment,
}
impl PackColors {
@@ -38,6 +69,7 @@ impl PackColors {
}
}
}
colors.env = colors.environment();
colors
}
@@ -93,6 +125,91 @@ impl PackColors {
self.color(&format!("sheep.{name}"))
}
/// `redstone.<power>`: the wire's and its dust particles' colour at that power (0 to 15).
pub fn redstone(&self, power: u8) -> Option<u32> {
self.color(&format!("redstone.{power}"))
}
/// `effect.<id>` or `effect.<id>.color` (the deprecated `potion.<id>` too): mob effects'
/// colours by id without vanilla's namespace, as the effects table names them. `water` and
/// `empty` are left out: they change nothing on the real mod.
pub fn effects(&self) -> Vec<(String, u32)> {
let mut out = Vec::new();
for (key, value) in &self.entries {
let Some(rest) = key
.strip_prefix("effect.")
.or_else(|| key.strip_prefix("potion."))
else {
continue;
};
let id = rest.strip_suffix(".color").unwrap_or(rest);
if id.ends_with(".particle") || matches!(id, "water" | "empty" | "minecraft:water") {
continue;
}
if let Some(color) = parse_color(value) {
let id = id.strip_prefix("minecraft:").unwrap_or(id);
// `effect.<id>.color` wins over the plain key whichever comes first.
out.retain(|(other, _)| other != id || !key.ends_with(".color"));
if !out.iter().any(|(other, _)| other == id) {
out.push((id.to_owned(), color));
}
}
}
out
}
/// `particle.<id>`: a colour or an expression each new particle of that type takes, by
/// namespaced type id.
pub fn particles(&self) -> Vec<(String, String)> {
self.entries
.iter()
.filter_map(|(key, value)| {
let id = key.strip_prefix("particle.")?;
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let value = match value {
Json::String(s) => s.trim().to_owned(),
Json::Number(n) => n.to_string(),
_ => return None,
};
Some((id, value))
})
.collect()
}
/// `environment.*`: the fog settings a pack replaces.
pub fn environment(&self) -> Environment {
let number = |key: &str, default: f32| {
match self.entries.get(&format!("environment.{key}")) {
Some(Json::Number(n)) => n.as_f64().map(|v| v as f32),
Some(Json::String(s)) => s.trim().parse().ok(),
_ => None,
}
.unwrap_or(default)
};
let vanilla = Environment::default();
Environment {
powder_snow_fog: self
.color("environment.powder_snow_fog")
.map(|c| c & 0xFF_FFFF),
blindness_fog_distance: number(
"blindness_fog_distance",
vanilla.blindness_fog_distance,
),
darkness_fog_distance: number("darkness_fog_distance", vanilla.darkness_fog_distance),
boss_fog_distance: number("boss_fog_distance", vanilla.boss_fog_distance),
water_fog_brightening: number("water_fog_brightening", vanilla.water_fog_brightening),
water_fog_brightening_time: number(
"water_fog_brightening_time",
vanilla.water_fog_brightening_time,
)
.max(1.0),
}
}
/// `entity_shadows`: false hides every entity's round shadow, whatever the video option says.
pub fn entity_shadows(&self) -> bool {
match self.entries.get("entity_shadows") {
@@ -161,6 +278,55 @@ mod tests {
assert_eq!(p.xp_bar(), [Some(0x00ff22), Some(u32::MAX)]);
}
#[test]
fn effects_particles_and_redstone_by_id() {
let mut c = PackColors::default();
c.add_json(
"",
&json!({
"effect": {"minecraft:speed": {"color": "ff0000", "particle": "minecraft:poof"}, "water": "00ff00", "poison": 7},
"particle": {"minecraft:flame": "0x0000ff", "poof": 0},
"redstone": {"15": "00ff00"}
}),
);
let mut effects = c.effects();
effects.sort();
assert_eq!(
effects,
[("poison".to_owned(), 7), ("speed".to_owned(), 0xff0000)]
);
let mut particles = c.particles();
particles.sort();
assert_eq!(
particles,
[
("minecraft:flame".to_owned(), "0x0000ff".to_owned()),
("minecraft:poof".to_owned(), "0".to_owned())
]
);
assert_eq!((c.redstone(15), c.redstone(0)), (Some(0x00ff00), None));
}
#[test]
fn environment_keeps_vanillas_where_unset() {
let mut c = PackColors::default();
assert_eq!(c.environment(), Environment::default());
c.add_json(
"",
&json!({"environment": {"powder_snow_fog": "#c0c0d0", "blindness_fog_distance": 40, "water_fog_brightening_time": "200"}}),
);
let e = c.environment();
assert_eq!(e.powder_snow_fog, Some(0xc0c0d0));
assert_eq!(
(e.blindness_fog_distance, e.darkness_fog_distance),
(40.0, 15.0)
);
assert_eq!(
(e.water_fog_brightening, e.water_fog_brightening_time),
(1.0, 200.0)
);
}
#[test]
fn dyes_name_their_colour_kind() {
let mut c = PackColors::default();
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "horizon-render-pipeline"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "What an external shader pipeline gives the renderer: its buffers, shadow map and programs. Engine layer; the pack formats that fill it are platform crates"
[dependencies]
# The frame uniform is uploaded as bytes (inputs.rs).
bytemuck = { version = "1.24", features = ["derive"] }
# A translated program's stages are naga modules (program.rs).
naga = "30.0.1"
[lints]
workspace = true
+33
View File
@@ -0,0 +1,33 @@
//! The lines of the WGSL stage interface ([`crate::stages`], [`crate::inputs`]).
/// A shader API line: the stages, their signatures and the inputs a pack's WGSL may read.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Api {
/// Vanilla's gamma values.
V0_1,
/// Linear light (docs/design/hdr-linear-light.md). Experimental: checked, not yet drawn.
V0_2,
}
impl Api {
/// Every line this build reads and checks, oldest first.
pub const SERVED: [Api; 2] = [Api::V0_1, Api::V0_2];
/// The manifest's `api` value.
pub const fn as_str(self) -> &'static str {
match self {
Api::V0_1 => "0.1",
Api::V0_2 => "0.2",
}
}
pub fn parse(line: &str) -> Option<Api> {
Self::SERVED.into_iter().find(|a| a.as_str() == line)
}
}
impl std::fmt::Display for Api {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
@@ -0,0 +1,266 @@
//! The pipeline's buffers and shadow map (spec 1.7, 6.1, 6.3, 7.1): each colortex buffer's
//! format, clearing and mipmaps, the shadow map's settings, and the names packs give them. A pack
//! format's reader fills a [`Config`] (`horizon-shaders` reads them from a `shaders/` pack's stage
//! files); the renderer makes its targets from it.
/// colortex buffers (6.1.1).
pub const BUFFERS: usize = 16;
/// The shadowcolor buffers there are: two, and `shadowcolor2` to `shadowcolor7` for a pack that
/// names the `HIGHER_SHADOWCOLOR` feature.
pub const SHADOW_COLORS: usize = 8;
/// A colour format of 6.7, as channels, bits per channel and kind. Packed formats are described
/// by their widest channel: `RGB10_A2` is 4 × 10 bits, `R11F_G11F_B10F` 3 × 11 bits float.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Format {
pub channels: u8,
pub bits: u8,
pub kind: Kind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Unorm,
Snorm,
Float,
Int,
Uint,
}
impl Format {
/// The default `RGBA` (6.1.3).
pub const RGBA8: Format = Format {
channels: 4,
bits: 8,
kind: Kind::Unorm,
};
/// A format name of 6.7, in any case.
pub fn parse(name: &str) -> Option<Format> {
let name = name.trim().to_ascii_uppercase();
let packed = |channels, bits, kind| {
Some(Format {
channels,
bits,
kind,
})
};
match name.as_str() {
"RGBA" => return Some(Self::RGBA8),
"R3_G3_B2" => return packed(3, 3, Kind::Unorm),
"RGB5_A1" => return packed(4, 5, Kind::Unorm),
"RGB10_A2" => return packed(4, 10, Kind::Unorm),
"RGB10_A2UI" => return packed(4, 10, Kind::Uint),
"R11F_G11F_B10F" => return packed(3, 11, Kind::Float),
"RGB9_E5" => return packed(3, 9, Kind::Float),
"RGBA4" => return packed(4, 4, Kind::Unorm),
"RGB565" => return packed(3, 6, Kind::Unorm),
"RGBA2" => return packed(4, 2, Kind::Unorm),
_ => {}
}
let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count();
let channels = match &name[..letters] {
"R" => 1,
"RG" => 2,
"RGB" => 3,
"RGBA" => 4,
_ => return None,
};
let rest = &name[letters..];
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let bits: u8 = rest[..digits].parse().ok()?;
let kind = match &rest[digits..] {
"" => Kind::Unorm,
"_SNORM" => Kind::Snorm,
"F" => Kind::Float,
"I" => Kind::Int,
"UI" => Kind::Uint,
_ => return None,
};
let valid = match kind {
Kind::Unorm | Kind::Snorm => matches!(bits, 8 | 16),
Kind::Float => matches!(bits, 16 | 32),
Kind::Int | Kind::Uint => matches!(bits, 8 | 16 | 32),
};
valid.then_some(Format {
channels,
bits,
kind,
})
}
}
/// The settings of one colortex buffer.
#[derive(Clone, Debug, PartialEq)]
pub struct Buffer {
pub format: Format,
/// Cleared every frame (`<buffer>Clear`, default true).
pub clear: bool,
/// `<buffer>ClearColor`, else 6.1.5's default: colortex0 the fog colour, colortex1 white,
/// the others transparent black.
pub clear_color: Option<[f32; 4]>,
/// `<buffer>MipmapEnabled`.
pub mipmap: bool,
}
impl Default for Buffer {
fn default() -> Self {
Self {
format: Format::RGBA8,
clear: true,
clear_color: None,
mipmap: false,
}
}
}
/// The shadow map's settings (6.3, 7.1), from `const` lines of the stage files.
#[derive(Clone, Debug, PartialEq)]
pub struct Shadow {
/// `shadowMapResolution`: the map's side in pixels.
pub resolution: u32,
/// `shadowDistance`: half the side of the area the map covers, in blocks.
pub distance: f32,
/// `shadowDistanceRenderMul`: when > 0, geometry beyond `distance × mul` is not drawn.
pub render_mul: f32,
/// `voxelDistance`: with `shadow.culling=reversed`, nothing within it is culled.
pub voxel_distance: f32,
/// `shadowIntervalSize`: the shadow camera moves in steps of this many blocks (0: none).
pub interval: f32,
/// Native packs: the snap's remainder is euclidean, so the centre is within half an interval
/// of the player at negative coordinates too. `shaders/` packs keep Iris's.
pub euclidean_snap: bool,
/// `sunPathRotation`, degrees.
pub sun_path_rotation: f32,
/// `shadowMapFov`: a perspective projection of this many degrees instead of orthographic.
pub fov: Option<f32>,
/// `shadowNearPlane`, `shadowFarPlane`: the orthographic projection's planes, as JOML's
/// `zNear`/`zFar`. None when the pack doesn't set one: Iris 1.11.7 then uses -100.05 and
/// 156 (measured with a probe pack), each on its own.
pub near: Option<f32>,
pub far: Option<f32>,
/// `shadowHardwareFiltering(0|1)`: shadowtex0/1 sampled with depth comparison.
pub hardware_filtering: [bool; 2],
/// `shadowtex0Nearest` (or `shadowtexNearest`, `shadow0MinMagNearest`) and
/// `shadowtex1Nearest` (or `shadow1MinMagNearest`): shadowtex0/1 read through a plain sampler
/// take the nearest texel. Otherwise Iris 1.11.7 filters them linearly (measured).
pub nearest: [bool; 2],
/// `shadowcolor0` to `shadowcolor7`: format, cleared every frame, clear colour (default
/// white).
pub colors: [Buffer; SHADOW_COLORS],
}
impl Default for Shadow {
fn default() -> Self {
let white = Buffer {
clear_color: Some([1.0; 4]),
..Buffer::default()
};
Self {
resolution: 1024,
distance: 160.0,
render_mul: -1.0,
voxel_distance: 160.0,
interval: 2.0,
euclidean_snap: false,
sun_path_rotation: 0.0,
fov: None,
near: None,
far: None,
hardware_filtering: [false; 2],
nearest: [false; 2],
colors: std::array::from_fn(|_| white.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Config {
pub buffers: [Buffer; BUFFERS],
pub shadow: Shadow,
/// `noiseTextureResolution`: the side of the `noisetex` Horizon makes when the pack gives none.
pub noise_resolution: Option<u32>,
/// The smoothed uniforms' half-lives in ticks, in [`HALF_LIVES`] order, when the pack sets
/// them (`const float wetnessHalflife = 30.0;`...).
pub half_lives: [Option<f32>; 4],
/// Conflicting or unreadable settings, as `file:line: message`.
pub notes: Vec<String>,
/// Each colortex setting the pack's files set so far and its value, so that its reader can
/// report a later file changing it.
pub settings: Vec<(String, String)>,
}
impl Default for Config {
fn default() -> Self {
Self {
buffers: std::array::from_fn(|_| Buffer::default()),
shadow: Shadow::default(),
noise_resolution: None,
half_lives: [None; 4],
notes: Vec::new(),
settings: Vec::new(),
}
}
}
/// The shadowcolor buffer a sampler or image name reads: `shadowcolor` and `shadowcolor0` to
/// `shadowcolor7`.
pub fn shadow_color(name: &str) -> Option<usize> {
match name.strip_prefix("shadowcolor")?.as_bytes() {
[] => Some(0),
&[d @ b'0'..=b'7'] => Some(usize::from(d - b'0')),
_ => None,
}
}
/// The colortex index of a buffer name or alias (6.1.1).
pub fn buffer_index(name: &str) -> Option<usize> {
let alias = [
"gcolor",
"gdepth",
"gnormal",
"composite",
"gaux1",
"gaux2",
"gaux3",
"gaux4",
];
if let Some(i) = alias.iter().position(|a| *a == name) {
return Some(i);
}
let n: usize = name.strip_prefix("colortex")?.parse().ok()?;
(n < BUFFERS).then_some(n)
}
/// The `const float` half-lives (ShaderDoc, OptiFine's shaders.txt) of `wetness` rising and
/// falling, `eyeBrightnessSmooth` and `centerDepthSmooth`, in ticks.
pub const HALF_LIVES: [&str; 4] = [
"wetnessHalflife",
"drynessHalflife",
"eyeBrightnessHalflife",
"centerDepthHalflife",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_parse() {
let f = |channels, bits, kind| {
Some(Format {
channels,
bits,
kind,
})
};
assert_eq!(Format::parse("rgba16f"), f(4, 16, Kind::Float));
assert_eq!(Format::parse("R32UI"), f(1, 32, Kind::Uint));
assert_eq!(Format::parse("RG8_SNORM"), f(2, 8, Kind::Snorm));
assert_eq!(Format::parse("R11F_G11F_B10F"), f(3, 11, Kind::Float));
assert_eq!(Format::parse("RGB16"), f(3, 16, Kind::Unorm));
assert_eq!(Format::parse("RGBA12"), None);
assert_eq!(Format::parse("R16_SNORMX"), None);
}
}
@@ -0,0 +1,380 @@
//! What a pack's WGSL reads: the frame uniform every stage sees, which the
//! renderer fills and uploads as the Rust struct below, and the stage input
//! and output structs. The WGSL of each is generated from these definitions
//! (`Struct::wgsl`), and so is the reference (docs/api/shaders-reference.md).
/// One field of a WGSL struct, for the prelude and the reference.
#[derive(Debug)]
pub struct Field {
pub name: &'static str,
pub ty: &'static str,
/// Its meaning, unit and range.
pub doc: &'static str,
}
/// A struct a pack's WGSL can name.
#[derive(Debug)]
pub struct Struct {
pub name: &'static str,
pub doc: &'static str,
pub fields: &'static [Field],
}
impl Struct {
pub fn wgsl(&self) -> String {
let mut out = format!("struct {} {{\n", self.name);
for f in self.fields {
out += &format!(" {}: {},\n", f.name, f.ty);
}
out + "}\n"
}
}
/// The WGSL type of a Rust field the renderer uploads.
pub trait Wgsl {
const TYPE: &'static str;
}
macro_rules! wgsl_types {
($($rust:ty => $wgsl:literal),* $(,)?) => {
$(impl Wgsl for $rust {
const TYPE: &'static str = $wgsl;
})*
};
}
wgsl_types! {
f32 => "f32",
u32 => "u32",
i32 => "i32",
[f32; 2] => "vec2<f32>",
[f32; 3] => "vec3<f32>",
[f32; 4] => "vec4<f32>",
[[f32; 4]; 4] => "mat4x4<f32>",
[[f32; 4]; 8] => "array<vec4<f32>, 8>",
}
/// A uniform struct: the Rust type the renderer uploads, and its WGSL. `pad` names a trailing
/// field in Rust only, which fills the struct out to WGSL's size (a multiple of its 16-byte
/// alignment), since `Pod` allows no implicit padding.
macro_rules! uniform {
($(#[doc = $doc:literal])* $name:ident {
$($(#[doc = $fdoc:literal])* $field:ident: $ty:ty,)*
} $(pad $pad:ident: $pad_ty:ty)?) => {
$(#[doc = $doc])*
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct $name {
$($(#[doc = $fdoc])* pub $field: $ty,)*
$(/// Not in the WGSL: zeros up to its size.
pub $pad: $pad_ty,)?
}
impl $name {
pub const WGSL: Struct = Struct {
name: stringify!($name),
doc: concat!($($doc, "\n"),*),
fields: &[$(Field {
name: stringify!($field),
ty: <$ty as Wgsl>::TYPE,
doc: concat!($($fdoc),*),
}),*],
};
/// Each field's byte offset, in `WGSL.fields`' order.
pub const OFFSETS: &'static [usize] = &[$(std::mem::offset_of!($name, $field)),*];
}
};
}
uniform! {
/// The frame, as `frame` in every stage. Positions and directions are
/// in world axes relative to the camera (x east, y up, z south), so they
/// stay precise far from the world's origin; depth is reversed (1 near,
/// 0 at the far plane and in the sky).
Frame {
/// Camera-relative world to view space: the camera's rotation.
view: [[f32; 4]; 4],
/// View to clip space (reversed depth, wgpu's clip space: z 0..1).
projection: [[f32; 4]; 4],
/// View to camera-relative world space.
view_inverse: [[f32; 4]; 4],
/// Clip to view space.
projection_inverse: [[f32; 4]; 4],
/// The camera's world position, in blocks (about a millimetre of precision 10 000 blocks out).
camera_position: [f32; 3],
/// Seconds since the pack was loaded, wrapping to 0 every hour.
time: f32,
/// Unit vector toward the sun (below the horizon at night).
sun_direction: [f32; 3],
/// The time of day, 0..1: 0 sunrise, 0.25 noon, 0.5 sunset, 0.75 midnight.
day_time: f32,
/// Unit vector toward the moon.
moon_direction: [f32; 3],
/// Vanilla's GameTime: (game time % 24000 + partial tick) / 24000, 0..1.
game_time: f32,
/// Vanilla's sky colour this frame, 0..1.
sky_color: [f32; 3],
/// Rain strength, 0..1.
rain: f32,
/// Vanilla's fog colour this frame (also the clear colour), 0..1.
fog_color: [f32; 3],
/// Thunder strength, 0..1.
thunder: f32,
/// The size of the world's picture, in pixels (the resolution scale applied).
resolution: [f32; 2],
/// Block and sky light at the camera, 0..1 (level / 15).
eye_light: [f32; 2],
/// Where vanilla's fog starts, in blocks from the camera.
fog_start: f32,
/// Where vanilla's fog is full, in blocks.
fog_end: f32,
/// The render distance, in blocks.
render_distance: f32,
/// The far plane, in blocks.
far: f32,
/// Night vision strength, 0..1.
night_vision: f32,
/// Blindness strength, 0..1.
blindness: f32,
/// The darkness effect's strength, 0..1.
darkness: f32,
/// The Brightness option, 0..1 (Moody to Bright).
brightness: f32,
/// What the camera is in: 0 air, 1 water, 2 lava, 3 powder snow.
eye_in: u32,
/// 0 the Overworld, -1 the Nether, 1 the End, 2 any other dimension.
dimension: i32,
/// Frames drawn since the pack was loaded (wraps).
frame_index: u32,
/// 1 while F1 hides the HUD, else 0.
hide_gui: u32,
/// What falls in the biome at the camera while it rains: 0 nothing, 1 rain, 2 snow.
precipitation: u32,
/// The biome at the camera's temperature (vanilla's, -0.7..2.0; it snows below 0.15).
temperature: f32,
/// The biome at the camera's downfall, 0..1.
downfall: f32,
/// `rain`, smoothed: rises over about 30 seconds and dries over about 10, 0..1.
wetness: f32,
/// Last frame's camera-relative world to clip space (its `projection * view`); a point
/// at camera-relative `p` now was at `p + camera_motion` relative to last frame's camera.
/// This frame's on the first frame and after a gap of over half a second (a pause, a
/// world load), when last frame's camera says nothing about this one.
previous_view_projection: [[f32; 4]; 4],
/// How far the camera moved since last frame, in blocks (0 after such a gap).
camera_motion: [f32; 3],
/// Seconds since last frame (0 on the first and after such a gap).
delta_time: f32,
/// `eye_light` eased with a half-life of 2 seconds: it follows the light levels' steps
/// without stepping itself (for exposure and indoor fog).
eye_light_smooth: [f32; 2],
/// 1 while the camera is in water (`eye_in` 1), eased with a half-life of a quarter
/// second in and out.
in_water: f32,
/// The same for lava (`eye_in` 2).
in_lava: f32,
/// The y of the horizontal water plane `reflection` mirrors the world about, in world
/// blocks, while `has_reflection` is 1.
water_plane: f32,
/// 1 while the material stage's `reflection` holds this frame's water mirror, else 0.
has_reflection: u32,
/// The size of `reflection`, in pixels: half the world's picture.
reflection_resolution: [f32; 2],
/// Eight values a native mod hands its own pack each frame (`native_shader_params`,
/// docs/native-mods.md): what they mean is that mod's, such as where and when
/// something happened in the world. All 0 when no mod sets them.
mods: [[f32; 4]; 8],
/// Unit vector toward the body casting this frame's shadows: the sun by day, the moon
/// by night.
light_direction: [f32; 3],
/// 1 while `shadow_map` holds this frame's shadow map, else 0.
has_shadow: u32,
/// Camera-relative world to the shadow camera's clip space, before the distortion
/// (orthographic, looking along `-light_direction`; z 0 at the light, 1 away from it).
shadow_view_proj: [[f32; 4]; 4],
/// Half the side of the square the shadow map covers, in blocks (the manifest's
/// `[shadow] distance`); 0 without a shadow map.
shadow_distance: f32,
/// How strongly the shadow map is distorted toward its centre, 0..1: a point at
/// shadow clip `xy` is stored at `xy / (length(xy) * k + 1 - k)`, so texels near the
/// camera cover less ground (`shadow_position` applies it).
shadow_distortion: f32,
/// The shadow map's side, in texels; 0 without a shadow map.
shadow_resolution: f32,
/// The ground one shadow texel covers at the map's centre, in blocks (for a bias that
/// scales with it); 0 without a shadow map.
shadow_center_texel: f32,
/// 1 while the output is HDR, else 0.
hdr: u32,
/// The peak over paper white: the brightest value the display shows (Apple's EDR
/// headroom, Android's HDR/SDR ratio). 1 in SDR. It changes while the game runs (the
/// system's SDR white, a calibration, another display), so read it every frame.
headroom: f32,
/// Paper white in nits; a nominal 80 in SDR.
paper_white: f32,
/// The peak brightness in nits; a nominal 80 in SDR, as `paper_white`.
peak: f32,
/// The display's black in nits (HDR's Black Level); 0 in SDR.
black_level: f32,
} pad padding: [u32; 3]
}
impl Frame {
/// The fields 0.1's prelude declares: the struct up to `shadow_center_texel`, a prefix of
/// the buffer the renderer uploads.
pub const FIELDS_0_1: usize = 49;
/// `Frame` as the line's prelude declares it.
pub fn line(api: crate::Api) -> Struct {
let fields = match api {
crate::Api::V0_1 => &Self::WGSL.fields[..Self::FIELDS_0_1],
crate::Api::V0_2 => Self::WGSL.fields,
};
Struct {
fields,
..Self::WGSL
}
}
}
macro_rules! fields {
($($name:ident: $ty:literal = $doc:literal,)*) => {
&[$(Field { name: stringify!($name), ty: $ty, doc: $doc }),*]
};
}
/// The post stage's input: one pixel of the picture.
pub const POST_INPUT: Struct = Struct {
name: "PostInput",
doc: "One pixel of the picture, for `fn post(input: PostInput) -> vec4<f32>`.",
fields: fields! {
uv: "vec2<f32>" = "Where the pixel is, 0..1 from the top left.",
pixel: "vec2<f32>" = "The pixel's centre, in pixels from the top left of what the pass draws (the world's size; the window's for the last pass).",
color: "vec4<f32>" = "The previous pass's colour here (the world's picture for the first pass).",
depth: "f32" = "The world's depth here, reversed: 0 is the sky.",
},
};
/// The material stage's input: one point of a block's surface.
pub const MATERIAL_INPUT: Struct = Struct {
name: "MaterialInput",
doc: "One point of a block's surface, for `fn material(input: MaterialInput) -> Surface`.",
fields: fields! {
albedo: "vec4<f32>" = "The block texture's colour here (alpha 0..1).",
tint: "vec4<f32>" = "The vertex colour: biome tint, face shade and ambient occlusion.",
light: "vec2<f32>" = "Block and sky light, 0..1, smoothed over the face.",
lightmap: "vec3<f32>" = "Vanilla's light colour for that light this frame.",
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
uv: "vec2<f32>" = "The texture coordinate inside the block atlas.",
translucent: "bool" = "Drawn in the translucent pass (water, stained glass, ice).",
tags: "u32" = "The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`.",
material: "u32" = "The `[[materials]]` id of this surface's sprite (0 when no entry names it).",
screen: "vec2<f32>" = "Where the point is on the screen, 0..1 from the top left (`opaque_depth` and `opaque_color` at `screen * frame.resolution`).",
face: "u32" = "For a fluid, which face of the block the point is on, `Direction` order: 0 down, 1 up, 2 north, 3 south, 4 west, 5 east. 0 elsewhere.",
fluid: "u32" = "0 not a fluid, 1 water, 2 lava.",
emission: "f32" = "How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks.",
normal: "vec3<f32>" = "The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs.",
tangent: "vec4<f32>" = "The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes.",
colored_light: "vec3<f32>" = "The frame's coloured lights at this point (`[[particle_lights]]`, held and dropped items in their `[[lights]]` colour while dynamic lights are on, and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.",
displaced: "f32" = "How much of the relief `[displacement]` gave this point as geometry, 0..1: 1 near the camera, where the surface already lies where `displacement` put it (`position` is that point; a parallax effect would count its depth twice), easing to 0 at `[displacement] distance`; always 0 without `[displacement]`.",
},
};
/// What the material stage returns.
pub const SURFACE: Struct = Struct {
name: "Surface",
doc: "What `material` returns. Vanilla's own is `albedo * tint * vec4(lightmap, 1.0)`; \
Horizon then applies the alpha test (cutout and translucent) and fog.",
fields: fields! {
color: "vec4<f32>" = "The lit colour, 0..1, with its alpha.",
},
};
/// The sky stage's input: one direction of the sky.
pub const SKY_INPUT: Struct = Struct {
name: "SkyInput",
doc: "One direction of the sky, for `fn sky(input: SkyInput) -> vec4<f32>`. The sun, moon, \
stars and clouds are drawn over the result.",
fields: fields! {
direction: "vec3<f32>" = "Unit view ray, camera-relative world axes.",
color: "vec4<f32>" = "Vanilla's sky colour along the ray (sky, fog and sunrise).",
},
};
/// The input of the material stage's `displacement`, under `[displacement]`.
pub const DISPLACEMENT_INPUT: Struct = Struct {
name: "DisplacementInput",
doc: "One corner of the grid a block face near the camera is cut into, for `fn displacement(input: DisplacementInput) -> f32` (`[displacement]`), which returns how deep below the face, in blocks, the surface lies at `height`.",
fields: fields! {
position: "vec3<f32>" = "The corner on the flat face, camera-relative, in blocks.",
normal: "vec3<f32>" = "The face's normal, a unit vector in world axes.",
uv: "vec2<f32>" = "The texture coordinate inside the block's sprite (as `MaterialInput.uv`).",
material: "u32" = "The `[[materials]]` id of the block texture (0 when none matches).",
height: "f32" = "A LabPBR height, 0..1 (1 the top of the relief): the normal map's alpha here, or the texture's average height (what the face's edges are pinned to).",
},
};
/// The vertex stage's input: one corner of a block's surface.
pub const VERTEX_INPUT: Struct = Struct {
name: "VertexInput",
doc: "One corner of a block's surface, for `fn vertex(input: VertexInput) -> vec3<f32>`, which returns where it is drawn (camera-relative, in blocks; `input.position` where it stays).",
fields: fields! {
position: "vec3<f32>" = "The corner, camera-relative, in blocks (`+ frame.camera_position` for the world's).",
uv: "vec2<f32>" = "The texture coordinate inside the block's sprite.",
material: "u32" = "The `[[materials]]` id of the block's sprite (0 when none matches).",
light: "vec2<f32>" = "Block and sky light, 0..1.",
tint: "vec4<f32>" = "The vertex colour (biome tint, face shade, ambient occlusion); alpha 1.",
face: "u32" = "For a fluid, the face of the block the corner is on (as `MaterialInput.face`), else 0.",
fluid: "u32" = "1 on a fluid's surface (water or lava), else 0.",
},
};
/// The weather stage's input: one point of a rain or snow streak.
pub const WEATHER_INPUT: Struct = Struct {
name: "WeatherInput",
doc: "One point of a rain or snow streak, for `fn weather(input: WeatherInput) -> vec4<f32>`, \
which returns the colour blended over the world (alpha its opacity).",
fields: fields! {
color: "vec4<f32>" = "The rain or snow texture's colour here times the streak's vertex colour (alpha 0.1..1).",
light: "vec2<f32>" = "Block and sky light, 0..1.",
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
snow: "bool" = "A snow streak (rain otherwise).",
},
};
/// The tonemap stage's input (0.2): one pixel of the window.
pub const TONEMAP_INPUT: Struct = Struct {
name: "TonemapInput",
doc: "One pixel of an output (the window), for `fn tonemap(input: TonemapInput) -> vec3<f32>`, which returns its colour for the output `frame` describes: linear light, 1.0 at paper white, from 0 to `frame.headroom` (Horizon clamps it there).",
fields: fields! {
uv: "vec2<f32>" = "Where the pixel is, 0..1 from the top left.",
pixel: "vec2<f32>" = "The pixel's centre, in pixels from the top left of the output (the window).",
color: "vec3<f32>" = "The post chain's picture here (the world's without one), linear light, never negative or NaN.",
},
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_fields_carry_their_docs() {
let wgsl = Frame::WGSL;
assert_eq!(wgsl.fields.len(), 54);
assert!(wgsl.fields.iter().all(|f| !f.doc.trim().is_empty()));
// 0.1's 49 fields, then 0.2's five and the padding to a multiple of 16.
assert_eq!(
std::mem::size_of::<Frame>(),
6 * 64 + 15 * 16 + 8 * 16 + 2 * 16
);
assert_eq!(Frame::OFFSETS[Frame::FIELDS_0_1], 6 * 64 + 15 * 16 + 8 * 16);
assert_eq!(
Frame::line(crate::Api::V0_1).fields.len(),
Frame::FIELDS_0_1
);
assert!(
wgsl.wgsl()
.contains(" camera_position: vec3<f32>,\n time: f32,\n")
);
}
}
+34
View File
@@ -0,0 +1,34 @@
//! The external shader pipeline the renderer runs (docs/layers.md, layer plan M6): what a shader
//! pack hands the renderer once its own format is read. The renderer (`horizon-client-render`,
//! engine) builds its targets and passes from these types and does nothing with them on
//! vanilla's path; the pack formats that fill them are platform crates (`horizon-shaders` reads
//! `shaders/` packs). No wgpu and no pack file here.
//!
//! - [`config`]: the colortex buffers and the shadow map: formats, clearing, mipmaps, settings.
//! - [`program`]: a translated program (naga modules and their interface) and the vertex
//! inputs the renderer offers it.
//! - [`textures`]: the custom textures the programs read; [`RENDER_STAGES`]: the stages they draw in.
//! - [`stages`], [`inputs`], [`Api`]: the WGSL stage interface Horizon's own packs are written
//! against (`horizon-shader-api` reads and checks those packs): the stages' functions, preludes,
//! bindings and entry points, and the frame uniform the renderer fills.
mod api;
pub mod config;
pub mod inputs;
pub mod program;
mod render_stage;
pub mod stages;
pub mod textures;
pub use api::Api;
pub use config::{
BUFFERS, Buffer, Config, Format, HALF_LIVES, Kind, SHADOW_COLORS, Shadow, buffer_index,
shadow_color,
};
pub use inputs::Frame;
pub use program::{
AlphaTest, ComputeProgram, GbuffersInputs, GeometryInputs, Interface, Program, WATER_MASK,
WATER_PATCHES,
};
pub use render_stage::{RENDER_STAGES, render_stage};
pub use stages::StageKind;
@@ -0,0 +1,199 @@
//! A translated program as the renderer builds its pipelines from it: its naga modules and the
//! interface they expect (uniforms, samplers, images, outputs), and what the renderer offers a
//! program's vertex stage (`GbuffersInputs`, `GeometryInputs`). `horizon-shaders` translates a
//! `shaders/` pack's GLSL into these.
/// What a program's stages need from the game.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Interface {
/// The uniform block at binding 0: its size and each value's name, GLSL type and offset.
/// Ours come first: `hz_target` (the target's width, height, 1/width, 1/height), and the
/// fixed-function fog the compatibility `gl_Fog` reads (OQ-28): `hz_fog_color` (vanilla's fog
/// colour) and `hz_fog` (density, start, end, 1 / (end - start)), then `hz_depth_linear`:
/// 1 where `shadowtex0`, `shadowtex1`, `shadow` and `watershadow` (x to w) read through a
/// plain sampler filter linearly, 0 where they take the nearest texel.
pub uniform_size: u32,
pub uniforms: Vec<Uniform>,
/// std140 initial contents; runtime values overwrite their members each frame.
pub initial_values: Vec<u8>,
/// Each sampler: texture at `binding`, sampler object at `binding + 1`.
pub samplers: Vec<Sampler>,
/// The fragment stage's output locations.
pub outputs: Vec<u32>,
/// A gbuffers program reads `DRAW_UNIFORMS`: the block at set 2, binding 0, set per draw.
pub draw: bool,
/// Storage images (`colorimg4`...), after the samplers.
pub images: Vec<Image>,
/// The SSBOs' bindings in set 4 (`bufferObject.<n>`).
pub buffers: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
pub name: String,
/// `image2D`, `uimage3D`...
pub ty: String,
/// The format qualifier (`rgba16f`).
pub format: String,
/// `readonly`, `writeonly`, empty for both, or `atomic` for an image an atomic function
/// uses.
pub access: String,
pub binding: u32,
/// A compute program's copy of image `before` as it was before the dispatch, which its
/// loads read (see `ordering`).
pub before: Option<String>,
}
/// A compute program (`deferred4_a.csh`): its module, what it reads and writes, and how many
/// work groups it runs: `workGroups`, or `workGroupsRender` times the target's size over the
/// local size (1, 1 when neither is set).
#[derive(Clone)]
pub struct ComputeProgram {
pub module: naga::Module,
pub interface: Interface,
/// Non-fatal translation diagnostics, located in the authored source.
pub notes: Vec<Error>,
pub work_groups: Option<[u32; 3]>,
pub work_groups_render: [f32; 2],
}
/// The uniforms a gbuffers program reads per draw (5.3's "D"), in a block of their own
/// (`HzDraw`, std140: four ints, a vec4; 32 bytes) the game sets for each draw.
pub const DRAW_UNIFORMS: [(&str, &str); 5] = [
("renderStage", "int"),
("entityId", "int"),
("blockEntityId", "int"),
("currentRenderedItemId", "int"),
("entityColor", "vec4"),
];
#[derive(Clone, Debug, PartialEq)]
pub struct Uniform {
pub name: String,
/// The type the pack declared (`float`, `ivec2`, `mat4`; `bool` is stored as an `int`).
pub ty: String,
pub offset: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Sampler {
pub name: String,
/// The type the pack declared: `sampler2D`, `sampler2DShadow`, `usampler2D`, `sampler3D`...
pub ty: String,
pub binding: u32,
}
/// A translation diagnostic at a pack file and line.
#[derive(Clone, Debug, PartialEq)]
pub struct Error {
pub file: String,
pub line: u32,
pub message: String,
}
#[derive(Clone)]
pub struct Program {
pub vertex: naga::Module,
pub fragment: naga::Module,
pub interface: Interface,
/// Non-fatal translation diagnostics, located in the authored source.
pub notes: Vec<Error>,
}
/// Where the program draws, which decides the conventions of 5.5.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Target {
/// Pack buffers: rows stored as GL stores them.
Buffers,
/// The window (`final`).
Window,
}
/// What a gbuffers-style program's vertex stage reads. The game writes it: it knows its vertex
/// formats, the translator only places it.
#[derive(Clone, Debug, Default)]
pub struct GbuffersInputs {
/// GLSL declarations: the vertex inputs and any bindings of set 1.
pub declarations: String,
/// GLSL statements that set `hz_Vertex`, `hz_Color`, `hz_MultiTexCoord0`,
/// `hz_MultiTexCoord1` and `hz_Normal` (the compatibility inputs), run first in `main`.
pub body: String,
/// The value of each attribute a pack may declare (`mc_Entity`, `vaPosition`...), as a GLSL
/// expression of the declared type's size; an attribute not listed reads zero. Terrain
/// also supplies `mc_chunkFade`, an implicit float input rather than a declared attribute.
pub attributes: Vec<(String, String)>,
/// GLSL statements run last in `main`, with `gl_Position` in wgpu's clip space.
pub after: String,
/// The alpha test applied to output 0 (`gl_FragData[0]`, `gl_FragColor`), the loader's
/// (3.2 `alphaTest.<program>`): `None` keeps every fragment.
pub alpha_test: Option<AlphaTest>,
/// The program may read `currentRenderedItemId` without declaring it, as Iris 1.11.7
/// lets the hand's (observed with probe packs; terrain's may not).
pub item_id_declared: bool,
/// The depth test sees the far plane (window depth 1) unless the program writes
/// `gl_FragDepth`, while `gl_FragCoord.z` stays the geometry's own: the sky shows only where
/// nothing else drew, and reads its own depth as in Iris 1.11.7.
pub far_depth: bool,
/// `normals` and `specular` are bound as paged maps of the atlas `texture` reads
/// (`horizon-client-render`'s `paged_maps.rs`), not as images of its layout: the program's
/// texture calls on them read through `hz_pagedTexture`.
pub paged_maps: bool,
/// Translucent terrain: a fragment behind the boats' water patches (`horizonWaterMask`, the
/// patches' depth, 1 where there are none) is discarded, so no water shows inside a hull
/// (`RenderTypes.waterMask`), while the frame's depth stays the pack's own.
pub water_mask: bool,
/// The shadow pass's translucent terrain: a fragment inside a boat's water patch
/// (`horizonWaterPatch*`, camera-relative boxes) is discarded, so no water casts its light
/// into a hull. Not with a geometry stage.
pub water_patches: bool,
}
/// The sampler of the boats' water patches' depth (`GbuffersInputs::water_mask`).
pub const WATER_MASK: &str = "horizonWaterMask";
/// How many boats' water patches the shadow pass keeps water out of (`GbuffersInputs::water_patches`).
pub const WATER_PATCHES: usize = 16;
/// A fragment stays when its output 0's alpha compares to `reference` as `op` says (a GLSL
/// comparison operator, `true` for GL's `ALWAYS`, `false` for `NEVER`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AlphaTest {
pub op: &'static str,
pub reference: f32,
}
impl AlphaTest {
/// `alphaTest.<program>`'s value: `off`, or a GL comparison and a reference.
pub fn parse(value: &str) -> Option<Option<Self>> {
let mut words = value.split_whitespace();
let func = words.next()?;
if func == "off" {
return Some(None);
}
let op = match func.trim_start_matches("GL_") {
"NEVER" => "false",
"LESS" => "<",
"EQUAL" => "==",
"LEQUAL" => "<=",
"GREATER" => ">",
"NOTEQUAL" => "!=",
"GEQUAL" => ">=",
"ALWAYS" => return Some(None),
_ => return None,
};
let reference = words.next()?.parse().ok()?;
Some(Some(Self { op, reference }))
}
}
/// How a geometry program's vertex invocations reach their triangle's vertices.
#[derive(Clone, Debug, Default)]
pub struct GeometryInputs {
/// The game's vertex inputs, as `GbuffersInputs`; their `layout(location = n) in`
/// declarations become plain globals the pull sets.
pub inputs: GbuffersInputs,
/// GLSL statements that set the vertex inputs from vertex `hz_vertex_index` (a `uint`).
pub pull: String,
/// The vertex of the `@`th corner of the draw (a `uint` expression, `hz_indices[@]`).
pub index: String,
}
@@ -0,0 +1,38 @@
//! The render stages a pipeline's programs are told they draw in (`renderStage`, and the
//! `MC_RENDER_STAGE_*` macros `horizon-shaders` defines from them).
/// `MC_RENDER_STAGE_*` in the order the stages draw (spec 5.4); each is its index (OQ-7).
pub const RENDER_STAGES: [&str; 24] = [
"NONE",
"SKY",
"SUNSET",
"CUSTOM_SKY",
"SUN",
"MOON",
"STARS",
"VOID",
"TERRAIN_SOLID",
"TERRAIN_CUTOUT_MIPPED",
"TERRAIN_CUTOUT",
"ENTITIES",
"BLOCK_ENTITIES",
"DESTROY",
"OUTLINE",
"DEBUG",
"HAND_SOLID",
"TERRAIN_TRANSLUCENT",
"TRIPWIRE",
"PARTICLES",
"CLOUDS",
"RAIN_SNOW",
"WORLD_BORDER",
"HAND_TRANSLUCENT",
];
/// The value of `MC_RENDER_STAGE_<name>` (OQ-7: its index in `RENDER_STAGES`).
pub fn render_stage(name: &str) -> u32 {
RENDER_STAGES
.iter()
.position(|s| *s == name)
.unwrap_or_else(|| panic!("no render stage {name}")) as u32
}
@@ -0,0 +1,803 @@
//! The stages a WGSL pack can fill, each a WGSL function with a fixed signature
//! that the renderer calls inside its own passes, and the WGSL put around a
//! pack's files to build a stage: the prelude before (the inputs and the
//! resources), the entry points after. `horizon-shader-api` composes a pack's
//! files between them.
use crate::Api;
use crate::inputs::{
DISPLACEMENT_INPUT, Frame, MATERIAL_INPUT, POST_INPUT, SKY_INPUT, SURFACE, Struct,
TONEMAP_INPUT, VERTEX_INPUT, WEATHER_INPUT,
};
/// The bind group of a pack's resources; groups 0 to 2 are the renderer's.
pub const GROUP: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StageKind {
/// The sky's colour along each view ray, before the sun, moon and stars.
Sky,
/// The colour of block surfaces (terrain), before the alpha test and fog.
Material,
/// Where each corner of a block's surface is drawn (waving plants, rolling water), for
/// the world and the shadow map.
Vertex,
/// The colour of rain and snow streaks.
Weather,
/// A full-screen pass over the world's picture, under the hand and HUD.
Post,
/// 0.2: the picture for an output (the display), once per pixel of it after the post chain,
/// before the camera overlays and the HUD.
Tonemap,
}
/// A resource a stage reads, declared by its prelude.
#[derive(Debug)]
pub struct Resource {
pub binding: u32,
pub name: &'static str,
pub ty: &'static str,
pub doc: &'static str,
}
const FRAME: Resource = Resource {
binding: 0,
name: "frame",
ty: "var<uniform> frame: Frame",
doc: "The frame (`Frame`).",
};
/// The shadow map at `binding`.
const fn shadow_map(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_map",
ty: "var shadow_map: texture_depth_2d",
doc: "This frame's shadow map: the depth of the solid terrain and entities the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything.",
}
}
/// The shadow map's comparison sampler at `binding`.
const fn shadow_sampler(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_sampler",
ty: "var shadow_sampler: sampler_comparison",
doc: "Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow.",
}
}
/// The shadow map with translucent terrain at `binding`.
const fn shadow_map_translucent(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_map_translucent",
ty: "var shadow_map_translucent: texture_depth_2d",
doc: "`shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of.",
}
}
/// The colour of the translucent terrain in the shadow map at `binding`.
const fn shadow_color(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_color",
ty: "var shadow_color: texture_2d<f32>",
doc: "The texture colour times the vertex colour (rgb: the tint, the face's directional shade and ambient occlusion) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`.",
}
}
/// A function the material and post preludes give packs.
pub const SHADOW_HELPERS: &str = "\
// The shadow map's distortion toward its centre, of a point at shadow clip `xy`.
fn shadow_distort(xy: vec2<f32>) -> vec2<f32> {
let k = frame.shadow_distortion;
return xy / (length(xy) * k + 1.0 - k);
}
// Where the camera-relative point `position` is stored in the shadow map: xy its 0..1 texture
// coordinate (from the top left), z its depth from the light, for `textureSampleCompareLevel`.
fn shadow_position(position: vec3<f32>) -> vec3<f32> {
let clip = frame.shadow_view_proj * vec4<f32>(position, 1.0);
let xy = shadow_distort(clip.xy);
// At most 1, the depth where nothing is: past the map's far end a point stays lit.
return vec3<f32>(xy.x * 0.5 + 0.5, 0.5 - xy.y * 0.5, min(clip.z, 1.0));
}
";
const LINEAR_SAMPLER: Resource = Resource {
binding: 4,
name: "linear_sampler",
ty: "var linear_sampler: sampler",
doc: "Bilinear filtering, clamped to the edges.",
};
const NEAREST_SAMPLER: Resource = Resource {
binding: 5,
name: "nearest_sampler",
ty: "var nearest_sampler: sampler",
doc: "Nearest texel, clamped to the edges.",
};
/// The tonemap stage's resources, at the post stage's bindings for the same names.
const TONEMAP_RESOURCES: [Resource; 4] = [
FRAME,
Resource {
binding: 3,
name: "previous",
ty: "var previous: texture_2d<f32>",
doc: "The post chain's picture at the window's size (without a post chain, the world's at the world's size), linear light: what `input.color` samples, for a curve that reads around the pixel.",
},
LINEAR_SAMPLER,
NEAREST_SAMPLER,
];
/// 0.2's colour boundary, in every 0.2 prelude: vanilla's 2.2 power both ways.
const COLOR_HELPERS: &str = "\
// Negatives and NaN made 0 (a comparison with NaN is false), so no `pow` sees them.
fn horizon_sanitize(c: vec3<f32>) -> vec3<f32> {
return select(vec3<f32>(0.0), c, c > vec3<f32>(0.0));
}
fn horizon_decode(c: vec3<f32>) -> vec3<f32> {
return pow(horizon_sanitize(c), vec3<f32>(2.2));
}
fn horizon_encode(c: vec3<f32>) -> vec3<f32> {
return pow(horizon_sanitize(c), vec3<f32>(1.0 / 2.2));
}
// Alpha is coverage: it stays as it is.
fn horizon_decode_rgba(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(horizon_decode(c.rgb), c.a);
}
fn horizon_encode_rgba(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(horizon_encode(c.rgb), c.a);
}
";
const POST_RESOURCES: [Resource; 12] = [
FRAME,
Resource {
binding: 1,
name: "scene_color",
ty: "var scene_color: texture_2d<f32>",
doc: "The world's picture before any pass.",
},
Resource {
binding: 2,
name: "scene_depth",
ty: "var scene_depth: texture_depth_2d",
doc: "The world's depth, reversed (1 near, 0 far and sky).",
},
Resource {
binding: 3,
name: "previous",
ty: "var previous: texture_2d<f32>",
doc: "The previous pass's output (`scene_color` for the first pass).",
},
LINEAR_SAMPLER,
NEAREST_SAMPLER,
shadow_map(6),
shadow_sampler(7),
Resource {
binding: 8,
name: "history",
ty: "var history: texture_2d<f32>",
doc: "Last frame's input to the last pass (what the pass before it wrote), for temporal passes (TAA, denoising): put such a pass second to last and it reads its own previous output; reproject with `frame.previous_view_projection`. Black on the first frame, after a gap of over half a second, and with a single pass.",
},
shadow_map_translucent(9),
shadow_color(10),
Resource {
binding: 11,
name: "scene_surface",
ty: "var scene_surface: texture_2d<u32>",
doc: "What the material stage's `set_surface` stored for the solid terrain at each pixel, unfiltered (`textureLoad`; its texels are the bits of floats, so `bitcast<vec4<f32>>` them): read it through the prelude's `surface_at`, which knows its layout and where it no longer shows. Blank without a material stage that calls `set_surface`.",
},
];
/// The material stage's resources: the frame, and the world as it was before translucent
/// terrain drew (for water and glass to see what lies behind them).
const MATERIAL_RESOURCES: [Resource; 11] = [
FRAME,
Resource {
binding: 1,
name: "reflection",
ty: "var reflection: texture_2d<f32>",
doc: "The water mirror: the world as a camera mirrored about the horizontal water plane around the camera (`frame.water_plane`) sees it, at half resolution, mapped like the world's picture, so water samples it at `input.screen`. Solid terrain, cut away below the plane, over the vanilla sky gradient: not the pack's `sky` stage, nor the sun, moon and stars. Drawn only for a pack whose material reads it, while `frame.has_reflection` is 1; else a 1×1 blank.",
},
Resource {
binding: 2,
name: "reflection_sampler",
ty: "var reflection_sampler: sampler",
doc: "Bilinear filtering, clamped to the edges.",
},
shadow_map(3),
shadow_sampler(4),
shadow_map_translucent(7),
shadow_color(8),
Resource {
binding: 6,
name: "opaque_depth",
ty: "var opaque_depth: texture_depth_2d",
doc: "The world's depth before translucent terrain (reversed, like `scene_depth`), for the translucent pass: `textureLoad(opaque_depth, vec2<i32>(input.screen * frame.resolution), 0)` is what lies behind water or glass. Stale while solid terrain draws.",
},
Resource {
binding: 9,
name: "block_light_colors",
ty: "var block_light_colors: texture_3d<f32>",
doc: "The colour of the block light around the camera, from the `[[lights]]` of the light sources nearby: rgb their colours averaged by how near each is, over 4; a how strongly they reach the cell. Read it through `block_light_color`.",
},
Resource {
binding: 10,
name: "block_light_grid",
ty: "var<uniform> block_light_grid: vec4<f32>",
doc: "Where `block_light_colors` lies: xyz its corner, camera-relative, w the side of one of its cells in blocks.",
},
Resource {
binding: 11,
name: "opaque_color",
ty: "var opaque_color: texture_2d<f32>",
doc: "The world's picture before translucent terrain (sky, solid terrain, entities), the size of `opaque_depth`, for the translucent pass: what a surface refracts. Read it with `textureLoad`. Stale while solid terrain draws; black when the world draws straight to the window (a pack without `[[post]]` passes, at full resolution).",
},
];
/// Functions the post prelude gives packs.
const POST_HELPERS: &str = "\
// The world's depth at `uv` (reversed: 0 is the sky).
fn depth_at(uv: vec2<f32>) -> f32 {
let size = vec2<f32>(textureDimensions(scene_depth));
let texel = vec2<i32>(clamp(uv * size, vec2<f32>(0.0), size - 1.0));
return textureLoad(scene_depth, texel, 0);
}
// The camera-relative world position, in blocks, of the point at `uv` and `depth`.
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
let view = frame.projection_inverse * clip;
return (frame.view_inverse * vec4<f32>(view.xyz / view.w, 0.0)).xyz;
}
";
/// What the material entries (the checker's below and the renderer's) share: the
/// surface's normal and tangent from the derivatives of its position and uv. The arguments are
/// the line's return statements of `block_light_color`, `block_texture` and
/// `block_texture_level`, the helpers that read colour.
macro_rules! material_helpers {
($light:literal, $texture:literal, $texture_level:literal) => {
concat!(
"\
// The colour of the block light at the camera-relative `position` (rgb, from the `[[lights]]`
// of the sources nearby) and how strongly they reach it (a, 0 where none does). Block light
// keeps its own brightness (`input.light.x`): this is its colour only.
fn block_light_color(position: vec3<f32>) -> vec4<f32> {
let size = vec3<f32>(textureDimensions(block_light_colors));
let uvw = (position - block_light_grid.xyz) / (block_light_grid.w * size);
if any(uvw < vec3<f32>(0.0)) || any(uvw > vec3<f32>(1.0)) {
return vec4<f32>(0.0);
}
let c = textureSampleLevel(block_light_colors, reflection_sampler, uvw, 0.0);
",
$light,
"}
// What the post stage's `surface_at` reads for this point of solid terrain: its `normal` (a
// unit vector in world axes), `smoothness` (0..1, say for reflections) and `data`, a value of
// the pack's own passed through. Unset, the point keeps the face's own normal and 0 for both;
// translucent terrain stores none.
var<private> horizon_surface: vec4<f32>;
fn set_surface(normal: vec3<f32>, smoothness: f32, data: f32) {
// Octahedral, y up: the upper half folds onto xz, the lower half unfolds around it.
let n = normal / (abs(normal.x) + abs(normal.y) + abs(normal.z));
var e = n.xz;
if n.y < 0.0 {
e = (1.0 - abs(n.zx)) * select(vec2<f32>(-1.0), vec2<f32>(1.0), n.xz >= vec2<f32>(0.0));
}
horizon_surface = vec4<f32>(bitcast<f32>(pack2x16snorm(e)), smoothness, data, 1.0);
}
// The point being shaded: its sprite's layer and mip level (the entries set them).
var<private> horizon_block_layer: u32;
var<private> horizon_block_lod: f32;
// The surface's block texture at `uv` (the sprite's space, as `input.uv`), without the tint:
// what `input.albedo` holds at the point itself, readable anywhere on the sprite, for normals
// or detail generated from the texture.
fn block_texture(uv: vec2<f32>) -> vec4<f32> {
",
$texture,
"}
// `block_texture` at mip level `lod` (0 the full-size texture).
fn block_texture_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
",
$texture_level,
"}
// The block texture's size, in texels (16 x 16 for vanilla's).
fn block_texture_size() -> vec2<f32> {
return horizon_block_size();
}
// The mip level `block_texture` reads at this point (0 up close, more far away).
fn block_texture_lod() -> f32 {
return horizon_block_lod;
}
// LabPBR: whether the resource packs ship normal (`_n`) or specular (`_s`) maps beside their
// block textures. Without them `block_normal_map` and `block_specular_map` give the defaults
// below.
fn block_pbr_maps() -> bool {
return horizon_block_pbr_present();
}
// The surface's LabPBR normal map at `uv` (the sprite's space, as `input.uv`): rg the normal's
// x and y in the texture's space (x along `input.tangent`, y down the texture; 0..1 for
// -1..1), b ambient occlusion (1 none), a height (1 the top). (0.5, 0.5, 1, 1) where a texture
// has none.
fn block_normal_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 0u);
}
// `block_normal_map` at the block texture's mip level `lod` (the same footprint on the surface,
// where the map is smaller than the texture).
fn block_normal_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 0u);
}
// The surface's LabPBR specular map at `uv`: r perceptual smoothness, g F0 up to 229/255 and
// a metal's id above, b porosity or subsurface scattering, a emission (up to 254/255; 1
// none). (0, 0, 0, 0) where a texture has none.
fn block_specular_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 1u);
}
// `block_specular_map` at the block texture's mip level `lod`.
fn block_specular_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 1u);
}
// The LabPBR maps' size, in texels (they may be smaller than the block's texture).
fn block_pbr_size() -> vec2<f32> {
return horizon_block_pbr_size();
}
// Block faces are flat: the derivatives of the position give the face's normal, turned
// toward the camera. Far away they lose precision, so a normal within about 3.6 degrees of
// an axis (every full block face) is that axis exactly: no shimmer on distant faces. The
// gentlest slope of flowing water, one level over a block, tilts about 6 degrees.
fn horizon_material_normal(position: vec3<f32>) -> vec3<f32> {
let n = normalize(cross(dpdx(position), dpdy(position)));
let a = abs(n);
let m = max(a.x, max(a.y, a.z));
let axis = select(select(vec3(0.0, 0.0, sign(n.z)), vec3(0.0, sign(n.y), 0.0), a.y == m),
vec3(sign(n.x), 0.0, 0.0), a.x == m);
let flat_n = select(n, axis, m > 0.998);
return select(flat_n, -flat_n, dot(flat_n, position) > 0.0);
}
// The direction `uv.x` grows along on the surface, and the sign of the one `uv.y` grows
// along against `cross(normal, tangent)`.
fn horizon_material_tangent(position: vec3<f32>, uv: vec2<f32>, normal: vec3<f32>) -> vec4<f32> {
let dp1 = dpdx(position);
let dp2 = dpdy(position);
let duv1 = dpdx(uv);
let duv2 = dpdy(uv);
let dp2perp = cross(dp2, normal);
let dp1perp = cross(normal, dp1);
let t = dp2perp * duv1.x + dp1perp * duv2.x;
let b = dp2perp * duv1.y + dp1perp * duv2.y;
if length(t) < 1e-12 {
return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}
let tangent = normalize(t - normal * dot(normal, t));
return vec4<f32>(tangent, select(-1.0, 1.0, dot(cross(normal, tangent), b) >= 0.0));
}
"
)
};
}
const MATERIAL_HELPERS: &str = material_helpers!(
" return vec4<f32>(c.rgb * 4.0, c.a);\n",
" return horizon_block_sample(uv, horizon_block_layer, horizon_block_lod);\n",
" return horizon_block_sample(uv, horizon_block_layer, lod);\n"
);
/// 0.2's: the `[[lights]]` colours and the block texture decoded (the LabPBR maps are data).
const MATERIAL_HELPERS_0_2: &str = material_helpers!(
" return vec4<f32>(horizon_decode(c.rgb * 4.0), c.a);\n",
" return horizon_decode_rgba(horizon_block_sample(uv, horizon_block_layer, horizon_block_lod));\n",
" return horizon_decode_rgba(horizon_block_sample(uv, horizon_block_layer, lod));\n"
);
/// The post prelude's `surface_at`, for the post helpers' end.
const POST_SURFACE: &str = "\
// The solid terrain's surface at a pixel, as the material stage's `set_surface` left it.
struct SurfaceData {
// False where the sky, an entity, water, glass or a particle shows instead.
present: bool,
// A unit vector in world axes.
normal: vec3<f32>,
smoothness: f32,
// The pack's own value, passed through.
data: f32,
}
fn surface_at(texel: vec2<i32>) -> SurfaceData {
let size = vec2<i32>(textureDimensions(scene_surface));
let t = clamp(texel, vec2<i32>(0), size - 1);
let s = bitcast<vec4<f32>>(textureLoad(scene_surface, t, 0));
let depth_size = vec2<i32>(textureDimensions(scene_depth));
let depth = textureLoad(scene_depth, clamp(texel, vec2<i32>(0), depth_size - 1), 0);
// The terrain's own depth is stored with it: anything drawn nearer since hides it.
let present = s.w > 0.0 && abs(s.w - depth) <= s.w * 1e-5;
let e = unpack2x16snorm(bitcast<u32>(s.x));
var n = vec3<f32>(e.x, 1.0 - abs(e.x) - abs(e.y), e.y);
if n.y < 0.0 {
n = vec3<f32>((1.0 - abs(n.z)) * sign(n.x), n.y, (1.0 - abs(n.x)) * sign(n.z));
}
return SurfaceData(present, select(vec3<f32>(0.0), normalize(n), present),
select(0.0, s.y, present), select(0.0, s.z, present));
}
";
/// The post stage's entry points, as the renderer builds them: a full-screen
/// triangle, and a fragment that calls the pack's `post` for each pixel.
const POST_ENTRIES: &str = "\
struct HorizonPostOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_post(@builtin(vertex_index) i: u32) -> HorizonPostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonPostOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_post(in: HorizonPostOut) -> @location(0) vec4<f32> {
let color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0);
return post(PostInput(in.uv, in.position.xy, color, depth_at(in.uv)));
}
";
/// What the renderer gives the material prelude's LabPBR functions (`native_material.rs`); the
/// check and the entries below put a stand-in in its place.
macro_rules! pbr_stub {
() => {
"fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
return vec4<f32>(1.0);
}
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(16.0);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
fn horizon_block_pbr_present() -> bool {
return false;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(16.0);
}
"
};
}
pub const PBR_STUB: &str = pbr_stub!();
/// Entry points with the material stage's inputs: the renderer calls
/// `material` from its own terrain shader in the same way. The arguments are the line's
/// `MaterialInput` and return statements.
macro_rules! material_entries {
($input:literal, $result:literal) => {
concat!(
pbr_stub!(),
"\
struct HorizonMaterialIn {
@builtin(position) position: vec4<f32>,
@location(0) albedo: vec4<f32>,
@location(1) tint: vec4<f32>,
@location(2) light: vec2<f32>,
@location(3) lightmap: vec3<f32>,
@location(4) world: vec3<f32>,
@location(5) uv: vec2<f32>,
@location(6) @interpolate(flat) translucent: u32,
@location(7) @interpolate(flat) tags: u32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
@location(11) @interpolate(flat) material: u32,
}
@fragment fn horizon_fs_material(in: HorizonMaterialIn) -> @location(0) vec4<f32> {
let fluid = select(0u, select(2u, 1u, in.translucent != 0u), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.world);
let tangent = horizon_material_tangent(in.world, in.uv, normal);
",
$input,
$result,
"}
"
)
};
}
const MATERIAL_ENTRIES: &str = material_entries!(
" let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3<f32>(0.0), 0.0);
",
" return material(input).color;\n"
);
/// 0.2's: the colours decoded on the way in, and the result encoded for the frame
/// (`[frame] blend = "gamma"`; under `"linear"` the renderer stores it as it is).
const MATERIAL_ENTRIES_0_2: &str = material_entries!(
" let input = MaterialInput(horizon_decode_rgba(in.albedo), horizon_decode_rgba(in.tint), in.light,
horizon_decode(in.lightmap), in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3<f32>(0.0), 0.0);
",
" return horizon_encode_rgba(material(input).color);\n"
);
/// Entry points with the sky stage's inputs: the renderer calls `sky` from
/// its own sky shader in the same way.
const SKY_ENTRIES: &str = "\
struct HorizonSkyIn {
@builtin(position) position: vec4<f32>,
@location(0) direction: vec3<f32>,
@location(1) color: vec4<f32>,
}
@fragment fn horizon_fs_sky(in: HorizonSkyIn) -> @location(0) vec4<f32> {
return sky(SkyInput(normalize(in.direction), in.color));
}
";
const SKY_ENTRIES_0_2: &str = "\
struct HorizonSkyIn {
@builtin(position) position: vec4<f32>,
@location(0) direction: vec3<f32>,
@location(1) color: vec4<f32>,
}
@fragment fn horizon_fs_sky(in: HorizonSkyIn) -> @location(0) vec4<f32> {
return horizon_encode_rgba(sky(SkyInput(normalize(in.direction), horizon_decode_rgba(in.color))));
}
";
/// An entry point with the weather stage's inputs: the renderer calls `weather` from its own
/// weather shader in the same way.
const VERTEX_ENTRIES: &str = "\
@vertex fn horizon_vs_vertex(
@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) light: vec2<f32>,
@location(3) tint: vec4<f32>, @location(4) material: u32, @location(5) flags: u32,
) -> @builtin(position) vec4<f32> {
let input = VertexInput(position, uv, material, light, tint, flags & 7u, flags >> 31u);
return frame.projection * frame.view * vec4<f32>(vertex(input), 1.0);
}
";
/// 0.2's: the tint is a colour, so it is decoded too.
const VERTEX_ENTRIES_0_2: &str = "\
@vertex fn horizon_vs_vertex(
@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) light: vec2<f32>,
@location(3) tint: vec4<f32>, @location(4) material: u32, @location(5) flags: u32,
) -> @builtin(position) vec4<f32> {
let input = VertexInput(position, uv, material, light, horizon_decode_rgba(tint), flags & 7u, flags >> 31u);
return frame.projection * frame.view * vec4<f32>(vertex(input), 1.0);
}
";
/// `fn displacement(input: DisplacementInput) -> f32`, the material stage's function
/// under `[displacement]`.
pub const DISPLACEMENT_SIGNATURE: &str = "fn displacement(input: DisplacementInput) -> f32";
/// An entry point calling the material stage's `displacement`, after its entries under
/// `[displacement]`: the renderer calls it from its own terrain vertex shader in the same way.
pub const DISPLACEMENT_ENTRIES: &str = "@vertex fn horizon_vs_displacement(
@location(0) position: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>,
@location(3) material: u32, @location(4) height: f32,
) -> @builtin(position) vec4<f32> {
let depth = displacement(DisplacementInput(position, normal, uv, material, height));
return frame.projection * frame.view * vec4<f32>(position - normal * depth, 1.0);
}
";
const WEATHER_ENTRIES: &str = "\
struct HorizonWeatherIn {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) light: vec2<f32>,
@location(2) world: vec3<f32>,
@location(3) @interpolate(flat) snow: u32,
}
@fragment fn horizon_fs_weather(in: HorizonWeatherIn) -> @location(0) vec4<f32> {
return weather(WeatherInput(in.color, in.light, in.world, in.snow != 0u));
}
";
const WEATHER_ENTRIES_0_2: &str = "\
struct HorizonWeatherIn {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) light: vec2<f32>,
@location(2) world: vec3<f32>,
@location(3) @interpolate(flat) snow: u32,
}
@fragment fn horizon_fs_weather(in: HorizonWeatherIn) -> @location(0) vec4<f32> {
let input = WeatherInput(horizon_decode_rgba(in.color), in.light, in.world, in.snow != 0u);
return horizon_encode_rgba(weather(input));
}
";
/// The post stage's entry points under 0.2: the chain's targets hold linear light, so nothing
/// converts, and the pass's alpha is ignored.
const POST_ENTRIES_0_2: &str = "\
struct HorizonPostOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_post(@builtin(vertex_index) i: u32) -> HorizonPostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonPostOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_post(in: HorizonPostOut) -> @location(0) vec4<f32> {
let color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0);
return vec4<f32>(post(PostInput(in.uv, in.position.xy, color, depth_at(in.uv))).rgb, 1.0);
}
";
/// The tonemap stage's entry points: a full-screen triangle at the window's size, and a fragment
/// that holds the result to what the display pass takes.
const TONEMAP_ENTRIES: &str = "\
struct HorizonTonemapOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_tonemap(@builtin(vertex_index) i: u32) -> HorizonTonemapOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonTonemapOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_tonemap(in: HorizonTonemapOut) -> @location(0) vec4<f32> {
let color = horizon_sanitize(textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb);
let out = tonemap(TonemapInput(in.uv, in.position.xy, color));
return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);
}
";
impl Api {
/// The stages the line has, in `StageKind::ALL`'s order.
pub fn stages(self) -> &'static [StageKind] {
match self {
Api::V0_1 => &StageKind::ALL[..5],
Api::V0_2 => &StageKind::ALL,
}
}
}
impl StageKind {
pub const ALL: [StageKind; 6] = [
StageKind::Sky,
StageKind::Vertex,
StageKind::Material,
StageKind::Weather,
StageKind::Post,
StageKind::Tonemap,
];
/// The manifest's name for the stage (`[sky]`, `[vertex]`, `[material]`, `[weather]`,
/// `[[post]]`, `[tonemap]`).
pub fn name(self) -> &'static str {
match self {
StageKind::Sky => "sky",
StageKind::Material => "material",
StageKind::Vertex => "vertex",
StageKind::Weather => "weather",
StageKind::Post => "post",
StageKind::Tonemap => "tonemap",
}
}
/// The function a pack defines: it has the stage's name.
pub fn function(self) -> &'static str {
self.name()
}
pub fn input(self) -> &'static Struct {
match self {
StageKind::Sky => &SKY_INPUT,
StageKind::Material => &MATERIAL_INPUT,
StageKind::Vertex => &VERTEX_INPUT,
StageKind::Weather => &WEATHER_INPUT,
StageKind::Post => &POST_INPUT,
StageKind::Tonemap => &TONEMAP_INPUT,
}
}
/// The type the function returns.
pub fn output(self) -> &'static str {
match self {
StageKind::Material => SURFACE.name,
StageKind::Vertex | StageKind::Tonemap => "vec3<f32>",
StageKind::Sky | StageKind::Weather | StageKind::Post => "vec4<f32>",
}
}
/// `fn <name>(input: <Input>) -> <Output>`.
pub fn signature(self) -> String {
format!(
"fn {}(input: {}) -> {}",
self.function(),
self.input().name,
self.output()
)
}
pub fn resources(self) -> &'static [Resource] {
match self {
StageKind::Post => &POST_RESOURCES,
StageKind::Sky | StageKind::Vertex | StageKind::Weather => std::slice::from_ref(&FRAME),
StageKind::Material => &MATERIAL_RESOURCES,
StageKind::Tonemap => &TONEMAP_RESOURCES,
}
}
/// Everything the pack's files may use under the line `api`, declared before them.
pub fn prelude(self, api: Api) -> String {
let mut out = match api {
Api::V0_1 => {
String::from("// Horizon shader API prelude (docs/api/shaders-reference.md)\n")
}
Api::V0_2 => String::from(
"// Horizon shader API 0.2 prelude (docs/api/shaders-reference-0.2.md)\n",
),
};
out += &Frame::line(api).wgsl();
out += &self.input().wgsl();
if self == StageKind::Material {
out += &SURFACE.wgsl();
// The vertex stage's file joins the material's module (`compose`).
out += &VERTEX_INPUT.wgsl();
out += &DISPLACEMENT_INPUT.wgsl();
}
for r in self.resources() {
out += &format!("@group({GROUP}) @binding({}) {};\n", r.binding, r.ty);
}
if api == Api::V0_2 {
out += COLOR_HELPERS;
}
match self {
StageKind::Post => {
out += POST_HELPERS;
out += POST_SURFACE;
out += SHADOW_HELPERS;
}
StageKind::Material => {
out += match api {
Api::V0_1 => MATERIAL_HELPERS,
Api::V0_2 => MATERIAL_HELPERS_0_2,
};
out += SHADOW_HELPERS;
}
StageKind::Vertex => out += SHADOW_HELPERS,
StageKind::Sky | StageKind::Weather | StageKind::Tonemap => {}
}
out
}
/// The entry points put after the pack's files under the line `api`.
pub fn entries(self, api: Api) -> &'static str {
match (api, self) {
(Api::V0_1, StageKind::Sky) => SKY_ENTRIES,
(Api::V0_1, StageKind::Material) => MATERIAL_ENTRIES,
(Api::V0_1, StageKind::Vertex) => VERTEX_ENTRIES,
(Api::V0_1, StageKind::Weather) => WEATHER_ENTRIES,
(Api::V0_1, StageKind::Post) => POST_ENTRIES,
(Api::V0_2, StageKind::Sky) => SKY_ENTRIES_0_2,
(Api::V0_2, StageKind::Material) => MATERIAL_ENTRIES_0_2,
(Api::V0_2, StageKind::Vertex) => VERTEX_ENTRIES_0_2,
(Api::V0_2, StageKind::Weather) => WEATHER_ENTRIES_0_2,
(Api::V0_2, StageKind::Post) => POST_ENTRIES_0_2,
// 0.1 has no tonemap stage: a manifest with one is refused before it is built.
(_, StageKind::Tonemap) => TONEMAP_ENTRIES,
}
}
}
/// The first binding of a pack's `[[textures]]` in group 3; their samplers follow at
/// `TEXTURE_SAMPLER_BINDING`.
pub const TEXTURE_BINDING: u32 = 16;
pub const TEXTURE_SAMPLER_BINDING: u32 = 24;
/// The binding of a pack's first post target (`[[post]] output`) in group 3, the others after.
pub const POST_TARGET_BINDING: u32 = 32;
/// Whether a material stage's `source` (with its prelude) calls `set_surface` itself, and so
/// needs the surface target, which a pack without it doesn't pay for.
pub fn calls_set_surface(source: &str) -> bool {
let call = "set_surface(";
source.matches(call).count() > MATERIAL_HELPERS.matches(call).count()
}
@@ -0,0 +1,71 @@
//! The custom textures a pipeline's programs read: an image of the pack (a PNG or a raw dump,
//! decoded) or a resource location the game resolves, bound to a sampler name in the programs of
//! a stage (`horizon-shaders` reads them from `shaders.properties`, spec 3.4).
use crate::Format;
/// The stage of a `customTexture.<name>`: every program's.
pub const EVERY_STAGE: &str = "*";
/// A texture bound to sampler `name` in the programs of `stage` (`gbuffers`, `deferred`,
/// `composite`...; [`EVERY_STAGE`] for a `customTexture`), or the noise texture (stage and
/// name `noise`).
#[derive(Clone, Debug)]
pub struct CustomTexture {
pub stage: String,
pub name: String,
pub source: TextureSource,
}
#[derive(Clone, Debug)]
pub enum TextureSource {
Image(Texture),
/// A resource location for the game to resolve.
Resource(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dim {
D1,
D2,
D3,
}
#[derive(Clone, Debug)]
pub struct Texture {
pub dim: Dim,
/// Width, height and depth (1 for the dimensions the texture lacks).
pub size: [u32; 3],
/// The internal format the pack asked for.
pub format: Format,
pub texels: Texels,
/// Linear filtering, and clamping instead of repeating: on for a raw texture and off for a
/// PNG, unless the `.mcmeta` beside it says otherwise.
pub blur: bool,
pub clamp: bool,
/// A raw dump rather than a PNG.
pub raw: bool,
}
/// Texel values, `components` per texel: 1, 2 or 4 (three widen to four, alpha 1).
#[derive(Clone, Debug, PartialEq)]
pub enum Texels {
Float(Vec<f32>),
Int(Vec<i32>),
Uint(Vec<u32>),
}
impl Texture {
pub fn components(&self) -> usize {
components(self.format.channels)
}
}
/// The components per texel a texture of `channels` channels stores: three widen to four.
pub fn components(channels: u8) -> usize {
match channels {
1 => 1,
2 => 2,
_ => 4,
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ rust-version.workspace = true
description = "Horizon's own shader packs (WGSL, docs/shaders.md): the format, the stages, the check, and the horizon-shader tool"
[dependencies]
bytemuck = { version = "1.24", features = ["derive"] }
# The stage interface packs are written against (the renderer's, engine).
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
# Parses and validates a pack's WGSL, and translates it as wgpu does for Vulkan, Metal and DirectX 12.
png = "0.18"
naga = { version = "30.0.1", features = ["wgsl-in", "spv-out", "msl-out", "hlsl-out"] }
+3 -380
View File
@@ -1,380 +1,3 @@
//! What a pack's WGSL reads: the frame uniform every stage sees, which the
//! renderer fills and uploads as the Rust struct below, and the stage input
//! and output structs. The WGSL of each is generated from these definitions
//! (`Struct::wgsl`), and so is the reference (docs/api/shaders-reference.md).
/// One field of a WGSL struct, for the prelude and the reference.
#[derive(Debug)]
pub struct Field {
pub name: &'static str,
pub ty: &'static str,
/// Its meaning, unit and range.
pub doc: &'static str,
}
/// A struct a pack's WGSL can name.
#[derive(Debug)]
pub struct Struct {
pub name: &'static str,
pub doc: &'static str,
pub fields: &'static [Field],
}
impl Struct {
pub fn wgsl(&self) -> String {
let mut out = format!("struct {} {{\n", self.name);
for f in self.fields {
out += &format!(" {}: {},\n", f.name, f.ty);
}
out + "}\n"
}
}
/// The WGSL type of a Rust field the renderer uploads.
pub trait Wgsl {
const TYPE: &'static str;
}
macro_rules! wgsl_types {
($($rust:ty => $wgsl:literal),* $(,)?) => {
$(impl Wgsl for $rust {
const TYPE: &'static str = $wgsl;
})*
};
}
wgsl_types! {
f32 => "f32",
u32 => "u32",
i32 => "i32",
[f32; 2] => "vec2<f32>",
[f32; 3] => "vec3<f32>",
[f32; 4] => "vec4<f32>",
[[f32; 4]; 4] => "mat4x4<f32>",
[[f32; 4]; 8] => "array<vec4<f32>, 8>",
}
/// A uniform struct: the Rust type the renderer uploads, and its WGSL. `pad` names a trailing
/// field in Rust only, which fills the struct out to WGSL's size (a multiple of its 16-byte
/// alignment), since `Pod` allows no implicit padding.
macro_rules! uniform {
($(#[doc = $doc:literal])* $name:ident {
$($(#[doc = $fdoc:literal])* $field:ident: $ty:ty,)*
} $(pad $pad:ident: $pad_ty:ty)?) => {
$(#[doc = $doc])*
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct $name {
$($(#[doc = $fdoc])* pub $field: $ty,)*
$(/// Not in the WGSL: zeros up to its size.
pub $pad: $pad_ty,)?
}
impl $name {
pub const WGSL: Struct = Struct {
name: stringify!($name),
doc: concat!($($doc, "\n"),*),
fields: &[$(Field {
name: stringify!($field),
ty: <$ty as Wgsl>::TYPE,
doc: concat!($($fdoc),*),
}),*],
};
/// Each field's byte offset, in `WGSL.fields`' order.
pub const OFFSETS: &'static [usize] = &[$(std::mem::offset_of!($name, $field)),*];
}
};
}
uniform! {
/// The frame, as `frame` in every stage. Positions and directions are
/// in world axes relative to the camera (x east, y up, z south), so they
/// stay precise far from the world's origin; depth is reversed (1 near,
/// 0 at the far plane and in the sky).
Frame {
/// Camera-relative world to view space: the camera's rotation.
view: [[f32; 4]; 4],
/// View to clip space (reversed depth, wgpu's clip space: z 0..1).
projection: [[f32; 4]; 4],
/// View to camera-relative world space.
view_inverse: [[f32; 4]; 4],
/// Clip to view space.
projection_inverse: [[f32; 4]; 4],
/// The camera's world position, in blocks (about a millimetre of precision 10 000 blocks out).
camera_position: [f32; 3],
/// Seconds since the pack was loaded, wrapping to 0 every hour.
time: f32,
/// Unit vector toward the sun (below the horizon at night).
sun_direction: [f32; 3],
/// The time of day, 0..1: 0 sunrise, 0.25 noon, 0.5 sunset, 0.75 midnight.
day_time: f32,
/// Unit vector toward the moon.
moon_direction: [f32; 3],
/// Vanilla's GameTime: (game time % 24000 + partial tick) / 24000, 0..1.
game_time: f32,
/// Vanilla's sky colour this frame, 0..1.
sky_color: [f32; 3],
/// Rain strength, 0..1.
rain: f32,
/// Vanilla's fog colour this frame (also the clear colour), 0..1.
fog_color: [f32; 3],
/// Thunder strength, 0..1.
thunder: f32,
/// The size of the world's picture, in pixels (the resolution scale applied).
resolution: [f32; 2],
/// Block and sky light at the camera, 0..1 (level / 15).
eye_light: [f32; 2],
/// Where vanilla's fog starts, in blocks from the camera.
fog_start: f32,
/// Where vanilla's fog is full, in blocks.
fog_end: f32,
/// The render distance, in blocks.
render_distance: f32,
/// The far plane, in blocks.
far: f32,
/// Night vision strength, 0..1.
night_vision: f32,
/// Blindness strength, 0..1.
blindness: f32,
/// The darkness effect's strength, 0..1.
darkness: f32,
/// The Brightness option, 0..1 (Moody to Bright).
brightness: f32,
/// What the camera is in: 0 air, 1 water, 2 lava, 3 powder snow.
eye_in: u32,
/// 0 the Overworld, -1 the Nether, 1 the End, 2 any other dimension.
dimension: i32,
/// Frames drawn since the pack was loaded (wraps).
frame_index: u32,
/// 1 while F1 hides the HUD, else 0.
hide_gui: u32,
/// What falls in the biome at the camera while it rains: 0 nothing, 1 rain, 2 snow.
precipitation: u32,
/// The biome at the camera's temperature (vanilla's, -0.7..2.0; it snows below 0.15).
temperature: f32,
/// The biome at the camera's downfall, 0..1.
downfall: f32,
/// `rain`, smoothed: rises over about 30 seconds and dries over about 10, 0..1.
wetness: f32,
/// Last frame's camera-relative world to clip space (its `projection * view`); a point
/// at camera-relative `p` now was at `p + camera_motion` relative to last frame's camera.
/// This frame's on the first frame and after a gap of over half a second (a pause, a
/// world load), when last frame's camera says nothing about this one.
previous_view_projection: [[f32; 4]; 4],
/// How far the camera moved since last frame, in blocks (0 after such a gap).
camera_motion: [f32; 3],
/// Seconds since last frame (0 on the first and after such a gap).
delta_time: f32,
/// `eye_light` eased with a half-life of 2 seconds: it follows the light levels' steps
/// without stepping itself (for exposure and indoor fog).
eye_light_smooth: [f32; 2],
/// 1 while the camera is in water (`eye_in` 1), eased with a half-life of a quarter
/// second in and out.
in_water: f32,
/// The same for lava (`eye_in` 2).
in_lava: f32,
/// The y of the horizontal water plane `reflection` mirrors the world about, in world
/// blocks, while `has_reflection` is 1.
water_plane: f32,
/// 1 while the material stage's `reflection` holds this frame's water mirror, else 0.
has_reflection: u32,
/// The size of `reflection`, in pixels: half the world's picture.
reflection_resolution: [f32; 2],
/// Eight values a native mod hands its own pack each frame (`native_shader_params`,
/// docs/native-mods.md): what they mean is that mod's, such as where and when
/// something happened in the world. All 0 when no mod sets them.
mods: [[f32; 4]; 8],
/// Unit vector toward the body casting this frame's shadows: the sun by day, the moon
/// by night.
light_direction: [f32; 3],
/// 1 while `shadow_map` holds this frame's shadow map, else 0.
has_shadow: u32,
/// Camera-relative world to the shadow camera's clip space, before the distortion
/// (orthographic, looking along `-light_direction`; z 0 at the light, 1 away from it).
shadow_view_proj: [[f32; 4]; 4],
/// Half the side of the square the shadow map covers, in blocks (the manifest's
/// `[shadow] distance`); 0 without a shadow map.
shadow_distance: f32,
/// How strongly the shadow map is distorted toward its centre, 0..1: a point at
/// shadow clip `xy` is stored at `xy / (length(xy) * k + 1 - k)`, so texels near the
/// camera cover less ground (`shadow_position` applies it).
shadow_distortion: f32,
/// The shadow map's side, in texels; 0 without a shadow map.
shadow_resolution: f32,
/// The ground one shadow texel covers at the map's centre, in blocks (for a bias that
/// scales with it); 0 without a shadow map.
shadow_center_texel: f32,
/// 1 while the output is HDR, else 0.
hdr: u32,
/// The peak over paper white: the brightest value the display shows (Apple's EDR
/// headroom, Android's HDR/SDR ratio). 1 in SDR. It changes while the game runs (the
/// system's SDR white, a calibration, another display), so read it every frame.
headroom: f32,
/// Paper white in nits; a nominal 80 in SDR.
paper_white: f32,
/// The peak brightness in nits; a nominal 80 in SDR, as `paper_white`.
peak: f32,
/// The display's black in nits (HDR's Black Level); 0 in SDR.
black_level: f32,
} pad padding: [u32; 3]
}
impl Frame {
/// The fields 0.1's prelude declares: the struct up to `shadow_center_texel`, a prefix of
/// the buffer the renderer uploads.
pub const FIELDS_0_1: usize = 49;
/// `Frame` as the line's prelude declares it.
pub fn line(api: crate::Api) -> Struct {
let fields = match api {
crate::Api::V0_1 => &Self::WGSL.fields[..Self::FIELDS_0_1],
crate::Api::V0_2 => Self::WGSL.fields,
};
Struct {
fields,
..Self::WGSL
}
}
}
macro_rules! fields {
($($name:ident: $ty:literal = $doc:literal,)*) => {
&[$(Field { name: stringify!($name), ty: $ty, doc: $doc }),*]
};
}
/// The post stage's input: one pixel of the picture.
pub const POST_INPUT: Struct = Struct {
name: "PostInput",
doc: "One pixel of the picture, for `fn post(input: PostInput) -> vec4<f32>`.",
fields: fields! {
uv: "vec2<f32>" = "Where the pixel is, 0..1 from the top left.",
pixel: "vec2<f32>" = "The pixel's centre, in pixels from the top left of what the pass draws (the world's size; the window's for the last pass).",
color: "vec4<f32>" = "The previous pass's colour here (the world's picture for the first pass).",
depth: "f32" = "The world's depth here, reversed: 0 is the sky.",
},
};
/// The material stage's input: one point of a block's surface.
pub const MATERIAL_INPUT: Struct = Struct {
name: "MaterialInput",
doc: "One point of a block's surface, for `fn material(input: MaterialInput) -> Surface`.",
fields: fields! {
albedo: "vec4<f32>" = "The block texture's colour here (alpha 0..1).",
tint: "vec4<f32>" = "The vertex colour: biome tint, face shade and ambient occlusion.",
light: "vec2<f32>" = "Block and sky light, 0..1, smoothed over the face.",
lightmap: "vec3<f32>" = "Vanilla's light colour for that light this frame.",
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
uv: "vec2<f32>" = "The texture coordinate inside the block atlas.",
translucent: "bool" = "Drawn in the translucent pass (water, stained glass, ice).",
tags: "u32" = "The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`.",
material: "u32" = "The `[[materials]]` id of this surface's sprite (0 when no entry names it).",
screen: "vec2<f32>" = "Where the point is on the screen, 0..1 from the top left (`opaque_depth` and `opaque_color` at `screen * frame.resolution`).",
face: "u32" = "For a fluid, which face of the block the point is on, `Direction` order: 0 down, 1 up, 2 north, 3 south, 4 west, 5 east. 0 elsewhere.",
fluid: "u32" = "0 not a fluid, 1 water, 2 lava.",
emission: "f32" = "How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks.",
normal: "vec3<f32>" = "The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs.",
tangent: "vec4<f32>" = "The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes.",
colored_light: "vec3<f32>" = "The frame's coloured lights at this point (`[[particle_lights]]`, held and dropped items in their `[[lights]]` colour while dynamic lights are on, and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.",
displaced: "f32" = "How much of the relief `[displacement]` gave this point as geometry, 0..1: 1 near the camera, where the surface already lies where `displacement` put it (`position` is that point; a parallax effect would count its depth twice), easing to 0 at `[displacement] distance`; always 0 without `[displacement]`.",
},
};
/// What the material stage returns.
pub const SURFACE: Struct = Struct {
name: "Surface",
doc: "What `material` returns. Vanilla's own is `albedo * tint * vec4(lightmap, 1.0)`; \
Horizon then applies the alpha test (cutout and translucent) and fog.",
fields: fields! {
color: "vec4<f32>" = "The lit colour, 0..1, with its alpha.",
},
};
/// The sky stage's input: one direction of the sky.
pub const SKY_INPUT: Struct = Struct {
name: "SkyInput",
doc: "One direction of the sky, for `fn sky(input: SkyInput) -> vec4<f32>`. The sun, moon, \
stars and clouds are drawn over the result.",
fields: fields! {
direction: "vec3<f32>" = "Unit view ray, camera-relative world axes.",
color: "vec4<f32>" = "Vanilla's sky colour along the ray (sky, fog and sunrise).",
},
};
/// The input of the material stage's `displacement`, under `[displacement]`.
pub const DISPLACEMENT_INPUT: Struct = Struct {
name: "DisplacementInput",
doc: "One corner of the grid a block face near the camera is cut into, for `fn displacement(input: DisplacementInput) -> f32` (`[displacement]`), which returns how deep below the face, in blocks, the surface lies at `height`.",
fields: fields! {
position: "vec3<f32>" = "The corner on the flat face, camera-relative, in blocks.",
normal: "vec3<f32>" = "The face's normal, a unit vector in world axes.",
uv: "vec2<f32>" = "The texture coordinate inside the block's sprite (as `MaterialInput.uv`).",
material: "u32" = "The `[[materials]]` id of the block texture (0 when none matches).",
height: "f32" = "A LabPBR height, 0..1 (1 the top of the relief): the normal map's alpha here, or the texture's average height (what the face's edges are pinned to).",
},
};
/// The vertex stage's input: one corner of a block's surface.
pub const VERTEX_INPUT: Struct = Struct {
name: "VertexInput",
doc: "One corner of a block's surface, for `fn vertex(input: VertexInput) -> vec3<f32>`, which returns where it is drawn (camera-relative, in blocks; `input.position` where it stays).",
fields: fields! {
position: "vec3<f32>" = "The corner, camera-relative, in blocks (`+ frame.camera_position` for the world's).",
uv: "vec2<f32>" = "The texture coordinate inside the block's sprite.",
material: "u32" = "The `[[materials]]` id of the block's sprite (0 when none matches).",
light: "vec2<f32>" = "Block and sky light, 0..1.",
tint: "vec4<f32>" = "The vertex colour (biome tint, face shade, ambient occlusion); alpha 1.",
face: "u32" = "For a fluid, the face of the block the corner is on (as `MaterialInput.face`), else 0.",
fluid: "u32" = "1 on a fluid's surface (water or lava), else 0.",
},
};
/// The weather stage's input: one point of a rain or snow streak.
pub const WEATHER_INPUT: Struct = Struct {
name: "WeatherInput",
doc: "One point of a rain or snow streak, for `fn weather(input: WeatherInput) -> vec4<f32>`, \
which returns the colour blended over the world (alpha its opacity).",
fields: fields! {
color: "vec4<f32>" = "The rain or snow texture's colour here times the streak's vertex colour (alpha 0.1..1).",
light: "vec2<f32>" = "Block and sky light, 0..1.",
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
snow: "bool" = "A snow streak (rain otherwise).",
},
};
/// The tonemap stage's input (0.2): one pixel of the window.
pub const TONEMAP_INPUT: Struct = Struct {
name: "TonemapInput",
doc: "One pixel of an output (the window), for `fn tonemap(input: TonemapInput) -> vec3<f32>`, which returns its colour for the output `frame` describes: linear light, 1.0 at paper white, from 0 to `frame.headroom` (Horizon clamps it there).",
fields: fields! {
uv: "vec2<f32>" = "Where the pixel is, 0..1 from the top left.",
pixel: "vec2<f32>" = "The pixel's centre, in pixels from the top left of the output (the window).",
color: "vec3<f32>" = "The post chain's picture here (the world's without one), linear light, never negative or NaN.",
},
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_fields_carry_their_docs() {
let wgsl = Frame::WGSL;
assert_eq!(wgsl.fields.len(), 54);
assert!(wgsl.fields.iter().all(|f| !f.doc.trim().is_empty()));
// 0.1's 49 fields, then 0.2's five and the padding to a multiple of 16.
assert_eq!(
std::mem::size_of::<Frame>(),
6 * 64 + 15 * 16 + 8 * 16 + 2 * 16
);
assert_eq!(Frame::OFFSETS[Frame::FIELDS_0_1], 6 * 64 + 15 * 16 + 8 * 16);
assert_eq!(
Frame::line(crate::Api::V0_1).fields.len(),
Frame::FIELDS_0_1
);
assert!(
wgsl.wgsl()
.contains(" camera_position: vec3<f32>,\n time: f32,\n")
);
}
}
//! What a pack's WGSL reads: the renderer's frame uniform and the stages' input and output
//! structs (`horizon_render_pipeline::inputs`).
pub use horizon_render_pipeline::inputs::*;
+1 -31
View File
@@ -9,37 +9,7 @@ pub const SCHEMA: u32 = 1;
/// templates write, and what the game and the registry take.
pub const API: &str = Api::V0_1.as_str();
/// A shader API line: the stages, their signatures and the inputs a pack's WGSL may read.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Api {
/// Vanilla's gamma values.
V0_1,
/// Linear light (docs/design/hdr-linear-light.md). Experimental: checked, not yet drawn.
V0_2,
}
impl Api {
/// Every line this build reads and checks, oldest first.
pub const SERVED: [Api; 2] = [Api::V0_1, Api::V0_2];
/// The manifest's `api` value.
pub const fn as_str(self) -> &'static str {
match self {
Api::V0_1 => "0.1",
Api::V0_2 => "0.2",
}
}
pub fn parse(line: &str) -> Option<Api> {
Self::SERVED.into_iter().find(|a| a.as_str() == line)
}
}
impl std::fmt::Display for Api {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub use horizon_render_pipeline::Api;
/// `[frame] blend` (0.2): what the frame stores, and so how see-through draws blend.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+5 -802
View File
@@ -1,797 +1,10 @@
//! The stages a pack can fill, each a WGSL function with a fixed signature
//! that Horizon calls inside its own passes, and the WGSL put around a
//! pack's files to build a stage: the prelude before (the inputs and the
//! resources), the entry points after.
use crate::inputs::{
DISPLACEMENT_INPUT, Frame, MATERIAL_INPUT, POST_INPUT, SKY_INPUT, SURFACE, Struct,
TONEMAP_INPUT, VERTEX_INPUT, WEATHER_INPUT,
};
use crate::manifest::Api;
//! The stages a pack can fill and the WGSL around its files: the stage interface is the
//! renderer's (`horizon_render_pipeline::stages`, re-exported here); this module puts a pack's
//! files and declarations between its prelude and its entry points.
pub use horizon_render_pipeline::stages::*;
use crate::package::Pack;
/// The bind group of a pack's resources; groups 0 to 2 are the renderer's.
pub const GROUP: u32 = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StageKind {
/// The sky's colour along each view ray, before the sun, moon and stars.
Sky,
/// The colour of block surfaces (terrain), before the alpha test and fog.
Material,
/// Where each corner of a block's surface is drawn (waving plants, rolling water), for
/// the world and the shadow map.
Vertex,
/// The colour of rain and snow streaks.
Weather,
/// A full-screen pass over the world's picture, under the hand and HUD.
Post,
/// 0.2: the picture for an output (the display), once per pixel of it after the post chain,
/// before the camera overlays and the HUD.
Tonemap,
}
/// A resource a stage reads, declared by its prelude.
#[derive(Debug)]
pub struct Resource {
pub binding: u32,
pub name: &'static str,
pub ty: &'static str,
pub doc: &'static str,
}
const FRAME: Resource = Resource {
binding: 0,
name: "frame",
ty: "var<uniform> frame: Frame",
doc: "The frame (`Frame`).",
};
/// The shadow map at `binding`.
const fn shadow_map(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_map",
ty: "var shadow_map: texture_depth_2d",
doc: "This frame's shadow map: the depth of the solid terrain and entities the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything.",
}
}
/// The shadow map's comparison sampler at `binding`.
const fn shadow_sampler(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_sampler",
ty: "var shadow_sampler: sampler_comparison",
doc: "Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow.",
}
}
/// The shadow map with translucent terrain at `binding`.
const fn shadow_map_translucent(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_map_translucent",
ty: "var shadow_map_translucent: texture_depth_2d",
doc: "`shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of.",
}
}
/// The colour of the translucent terrain in the shadow map at `binding`.
const fn shadow_color(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_color",
ty: "var shadow_color: texture_2d<f32>",
doc: "The texture colour times the vertex colour (rgb: the tint, the face's directional shade and ambient occlusion) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`.",
}
}
/// A function the material and post preludes give packs.
pub const SHADOW_HELPERS: &str = "\
// The shadow map's distortion toward its centre, of a point at shadow clip `xy`.
fn shadow_distort(xy: vec2<f32>) -> vec2<f32> {
let k = frame.shadow_distortion;
return xy / (length(xy) * k + 1.0 - k);
}
// Where the camera-relative point `position` is stored in the shadow map: xy its 0..1 texture
// coordinate (from the top left), z its depth from the light, for `textureSampleCompareLevel`.
fn shadow_position(position: vec3<f32>) -> vec3<f32> {
let clip = frame.shadow_view_proj * vec4<f32>(position, 1.0);
let xy = shadow_distort(clip.xy);
// At most 1, the depth where nothing is: past the map's far end a point stays lit.
return vec3<f32>(xy.x * 0.5 + 0.5, 0.5 - xy.y * 0.5, min(clip.z, 1.0));
}
";
const LINEAR_SAMPLER: Resource = Resource {
binding: 4,
name: "linear_sampler",
ty: "var linear_sampler: sampler",
doc: "Bilinear filtering, clamped to the edges.",
};
const NEAREST_SAMPLER: Resource = Resource {
binding: 5,
name: "nearest_sampler",
ty: "var nearest_sampler: sampler",
doc: "Nearest texel, clamped to the edges.",
};
/// The tonemap stage's resources, at the post stage's bindings for the same names.
const TONEMAP_RESOURCES: [Resource; 4] = [
FRAME,
Resource {
binding: 3,
name: "previous",
ty: "var previous: texture_2d<f32>",
doc: "The post chain's picture at the window's size (without a post chain, the world's at the world's size), linear light: what `input.color` samples, for a curve that reads around the pixel.",
},
LINEAR_SAMPLER,
NEAREST_SAMPLER,
];
/// 0.2's colour boundary, in every 0.2 prelude: vanilla's 2.2 power both ways.
const COLOR_HELPERS: &str = "\
// Negatives and NaN made 0 (a comparison with NaN is false), so no `pow` sees them.
fn horizon_sanitize(c: vec3<f32>) -> vec3<f32> {
return select(vec3<f32>(0.0), c, c > vec3<f32>(0.0));
}
fn horizon_decode(c: vec3<f32>) -> vec3<f32> {
return pow(horizon_sanitize(c), vec3<f32>(2.2));
}
fn horizon_encode(c: vec3<f32>) -> vec3<f32> {
return pow(horizon_sanitize(c), vec3<f32>(1.0 / 2.2));
}
// Alpha is coverage: it stays as it is.
fn horizon_decode_rgba(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(horizon_decode(c.rgb), c.a);
}
fn horizon_encode_rgba(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(horizon_encode(c.rgb), c.a);
}
";
const POST_RESOURCES: [Resource; 12] = [
FRAME,
Resource {
binding: 1,
name: "scene_color",
ty: "var scene_color: texture_2d<f32>",
doc: "The world's picture before any pass.",
},
Resource {
binding: 2,
name: "scene_depth",
ty: "var scene_depth: texture_depth_2d",
doc: "The world's depth, reversed (1 near, 0 far and sky).",
},
Resource {
binding: 3,
name: "previous",
ty: "var previous: texture_2d<f32>",
doc: "The previous pass's output (`scene_color` for the first pass).",
},
LINEAR_SAMPLER,
NEAREST_SAMPLER,
shadow_map(6),
shadow_sampler(7),
Resource {
binding: 8,
name: "history",
ty: "var history: texture_2d<f32>",
doc: "Last frame's input to the last pass (what the pass before it wrote), for temporal passes (TAA, denoising): put such a pass second to last and it reads its own previous output; reproject with `frame.previous_view_projection`. Black on the first frame, after a gap of over half a second, and with a single pass.",
},
shadow_map_translucent(9),
shadow_color(10),
Resource {
binding: 11,
name: "scene_surface",
ty: "var scene_surface: texture_2d<u32>",
doc: "What the material stage's `set_surface` stored for the solid terrain at each pixel, unfiltered (`textureLoad`; its texels are the bits of floats, so `bitcast<vec4<f32>>` them): read it through the prelude's `surface_at`, which knows its layout and where it no longer shows. Blank without a material stage that calls `set_surface`.",
},
];
/// The material stage's resources: the frame, and the world as it was before translucent
/// terrain drew (for water and glass to see what lies behind them).
const MATERIAL_RESOURCES: [Resource; 11] = [
FRAME,
Resource {
binding: 1,
name: "reflection",
ty: "var reflection: texture_2d<f32>",
doc: "The water mirror: the world as a camera mirrored about the horizontal water plane around the camera (`frame.water_plane`) sees it, at half resolution, mapped like the world's picture, so water samples it at `input.screen`. Solid terrain, cut away below the plane, over the vanilla sky gradient: not the pack's `sky` stage, nor the sun, moon and stars. Drawn only for a pack whose material reads it, while `frame.has_reflection` is 1; else a 1×1 blank.",
},
Resource {
binding: 2,
name: "reflection_sampler",
ty: "var reflection_sampler: sampler",
doc: "Bilinear filtering, clamped to the edges.",
},
shadow_map(3),
shadow_sampler(4),
shadow_map_translucent(7),
shadow_color(8),
Resource {
binding: 6,
name: "opaque_depth",
ty: "var opaque_depth: texture_depth_2d",
doc: "The world's depth before translucent terrain (reversed, like `scene_depth`), for the translucent pass: `textureLoad(opaque_depth, vec2<i32>(input.screen * frame.resolution), 0)` is what lies behind water or glass. Stale while solid terrain draws.",
},
Resource {
binding: 9,
name: "block_light_colors",
ty: "var block_light_colors: texture_3d<f32>",
doc: "The colour of the block light around the camera, from the `[[lights]]` of the light sources nearby: rgb their colours averaged by how near each is, over 4; a how strongly they reach the cell. Read it through `block_light_color`.",
},
Resource {
binding: 10,
name: "block_light_grid",
ty: "var<uniform> block_light_grid: vec4<f32>",
doc: "Where `block_light_colors` lies: xyz its corner, camera-relative, w the side of one of its cells in blocks.",
},
Resource {
binding: 11,
name: "opaque_color",
ty: "var opaque_color: texture_2d<f32>",
doc: "The world's picture before translucent terrain (sky, solid terrain, entities), the size of `opaque_depth`, for the translucent pass: what a surface refracts. Read it with `textureLoad`. Stale while solid terrain draws; black when the world draws straight to the window (a pack without `[[post]]` passes, at full resolution).",
},
];
/// Functions the post prelude gives packs.
const POST_HELPERS: &str = "\
// The world's depth at `uv` (reversed: 0 is the sky).
fn depth_at(uv: vec2<f32>) -> f32 {
let size = vec2<f32>(textureDimensions(scene_depth));
let texel = vec2<i32>(clamp(uv * size, vec2<f32>(0.0), size - 1.0));
return textureLoad(scene_depth, texel, 0);
}
// The camera-relative world position, in blocks, of the point at `uv` and `depth`.
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
let view = frame.projection_inverse * clip;
return (frame.view_inverse * vec4<f32>(view.xyz / view.w, 0.0)).xyz;
}
";
/// What the material entries (the checker's below and the renderer's) share: the
/// surface's normal and tangent from the derivatives of its position and uv. The arguments are
/// the line's return statements of `block_light_color`, `block_texture` and
/// `block_texture_level`, the helpers that read colour.
macro_rules! material_helpers {
($light:literal, $texture:literal, $texture_level:literal) => {
concat!(
"\
// The colour of the block light at the camera-relative `position` (rgb, from the `[[lights]]`
// of the sources nearby) and how strongly they reach it (a, 0 where none does). Block light
// keeps its own brightness (`input.light.x`): this is its colour only.
fn block_light_color(position: vec3<f32>) -> vec4<f32> {
let size = vec3<f32>(textureDimensions(block_light_colors));
let uvw = (position - block_light_grid.xyz) / (block_light_grid.w * size);
if any(uvw < vec3<f32>(0.0)) || any(uvw > vec3<f32>(1.0)) {
return vec4<f32>(0.0);
}
let c = textureSampleLevel(block_light_colors, reflection_sampler, uvw, 0.0);
",
$light,
"}
// What the post stage's `surface_at` reads for this point of solid terrain: its `normal` (a
// unit vector in world axes), `smoothness` (0..1, say for reflections) and `data`, a value of
// the pack's own passed through. Unset, the point keeps the face's own normal and 0 for both;
// translucent terrain stores none.
var<private> horizon_surface: vec4<f32>;
fn set_surface(normal: vec3<f32>, smoothness: f32, data: f32) {
// Octahedral, y up: the upper half folds onto xz, the lower half unfolds around it.
let n = normal / (abs(normal.x) + abs(normal.y) + abs(normal.z));
var e = n.xz;
if n.y < 0.0 {
e = (1.0 - abs(n.zx)) * select(vec2<f32>(-1.0), vec2<f32>(1.0), n.xz >= vec2<f32>(0.0));
}
horizon_surface = vec4<f32>(bitcast<f32>(pack2x16snorm(e)), smoothness, data, 1.0);
}
// The point being shaded: its sprite's layer and mip level (the entries set them).
var<private> horizon_block_layer: u32;
var<private> horizon_block_lod: f32;
// The surface's block texture at `uv` (the sprite's space, as `input.uv`), without the tint:
// what `input.albedo` holds at the point itself, readable anywhere on the sprite, for normals
// or detail generated from the texture.
fn block_texture(uv: vec2<f32>) -> vec4<f32> {
",
$texture,
"}
// `block_texture` at mip level `lod` (0 the full-size texture).
fn block_texture_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
",
$texture_level,
"}
// The block texture's size, in texels (16 x 16 for vanilla's).
fn block_texture_size() -> vec2<f32> {
return horizon_block_size();
}
// The mip level `block_texture` reads at this point (0 up close, more far away).
fn block_texture_lod() -> f32 {
return horizon_block_lod;
}
// LabPBR: whether the resource packs ship normal (`_n`) or specular (`_s`) maps beside their
// block textures. Without them `block_normal_map` and `block_specular_map` give the defaults
// below.
fn block_pbr_maps() -> bool {
return horizon_block_pbr_present();
}
// The surface's LabPBR normal map at `uv` (the sprite's space, as `input.uv`): rg the normal's
// x and y in the texture's space (x along `input.tangent`, y down the texture; 0..1 for
// -1..1), b ambient occlusion (1 none), a height (1 the top). (0.5, 0.5, 1, 1) where a texture
// has none.
fn block_normal_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 0u);
}
// `block_normal_map` at the block texture's mip level `lod` (the same footprint on the surface,
// where the map is smaller than the texture).
fn block_normal_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 0u);
}
// The surface's LabPBR specular map at `uv`: r perceptual smoothness, g F0 up to 229/255 and
// a metal's id above, b porosity or subsurface scattering, a emission (up to 254/255; 1
// none). (0, 0, 0, 0) where a texture has none.
fn block_specular_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 1u);
}
// `block_specular_map` at the block texture's mip level `lod`.
fn block_specular_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 1u);
}
// The LabPBR maps' size, in texels (they may be smaller than the block's texture).
fn block_pbr_size() -> vec2<f32> {
return horizon_block_pbr_size();
}
// Block faces are flat: the derivatives of the position give the face's normal, turned
// toward the camera. Far away they lose precision, so a normal within about 3.6 degrees of
// an axis (every full block face) is that axis exactly: no shimmer on distant faces. The
// gentlest slope of flowing water, one level over a block, tilts about 6 degrees.
fn horizon_material_normal(position: vec3<f32>) -> vec3<f32> {
let n = normalize(cross(dpdx(position), dpdy(position)));
let a = abs(n);
let m = max(a.x, max(a.y, a.z));
let axis = select(select(vec3(0.0, 0.0, sign(n.z)), vec3(0.0, sign(n.y), 0.0), a.y == m),
vec3(sign(n.x), 0.0, 0.0), a.x == m);
let flat_n = select(n, axis, m > 0.998);
return select(flat_n, -flat_n, dot(flat_n, position) > 0.0);
}
// The direction `uv.x` grows along on the surface, and the sign of the one `uv.y` grows
// along against `cross(normal, tangent)`.
fn horizon_material_tangent(position: vec3<f32>, uv: vec2<f32>, normal: vec3<f32>) -> vec4<f32> {
let dp1 = dpdx(position);
let dp2 = dpdy(position);
let duv1 = dpdx(uv);
let duv2 = dpdy(uv);
let dp2perp = cross(dp2, normal);
let dp1perp = cross(normal, dp1);
let t = dp2perp * duv1.x + dp1perp * duv2.x;
let b = dp2perp * duv1.y + dp1perp * duv2.y;
if length(t) < 1e-12 {
return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}
let tangent = normalize(t - normal * dot(normal, t));
return vec4<f32>(tangent, select(-1.0, 1.0, dot(cross(normal, tangent), b) >= 0.0));
}
"
)
};
}
const MATERIAL_HELPERS: &str = material_helpers!(
" return vec4<f32>(c.rgb * 4.0, c.a);\n",
" return horizon_block_sample(uv, horizon_block_layer, horizon_block_lod);\n",
" return horizon_block_sample(uv, horizon_block_layer, lod);\n"
);
/// 0.2's: the `[[lights]]` colours and the block texture decoded (the LabPBR maps are data).
const MATERIAL_HELPERS_0_2: &str = material_helpers!(
" return vec4<f32>(horizon_decode(c.rgb * 4.0), c.a);\n",
" return horizon_decode_rgba(horizon_block_sample(uv, horizon_block_layer, horizon_block_lod));\n",
" return horizon_decode_rgba(horizon_block_sample(uv, horizon_block_layer, lod));\n"
);
/// The post prelude's `surface_at`, for the post helpers' end.
const POST_SURFACE: &str = "\
// The solid terrain's surface at a pixel, as the material stage's `set_surface` left it.
struct SurfaceData {
// False where the sky, an entity, water, glass or a particle shows instead.
present: bool,
// A unit vector in world axes.
normal: vec3<f32>,
smoothness: f32,
// The pack's own value, passed through.
data: f32,
}
fn surface_at(texel: vec2<i32>) -> SurfaceData {
let size = vec2<i32>(textureDimensions(scene_surface));
let t = clamp(texel, vec2<i32>(0), size - 1);
let s = bitcast<vec4<f32>>(textureLoad(scene_surface, t, 0));
let depth_size = vec2<i32>(textureDimensions(scene_depth));
let depth = textureLoad(scene_depth, clamp(texel, vec2<i32>(0), depth_size - 1), 0);
// The terrain's own depth is stored with it: anything drawn nearer since hides it.
let present = s.w > 0.0 && abs(s.w - depth) <= s.w * 1e-5;
let e = unpack2x16snorm(bitcast<u32>(s.x));
var n = vec3<f32>(e.x, 1.0 - abs(e.x) - abs(e.y), e.y);
if n.y < 0.0 {
n = vec3<f32>((1.0 - abs(n.z)) * sign(n.x), n.y, (1.0 - abs(n.x)) * sign(n.z));
}
return SurfaceData(present, select(vec3<f32>(0.0), normalize(n), present),
select(0.0, s.y, present), select(0.0, s.z, present));
}
";
/// The post stage's entry points, as the renderer builds them: a full-screen
/// triangle, and a fragment that calls the pack's `post` for each pixel.
const POST_ENTRIES: &str = "\
struct HorizonPostOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_post(@builtin(vertex_index) i: u32) -> HorizonPostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonPostOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_post(in: HorizonPostOut) -> @location(0) vec4<f32> {
let color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0);
return post(PostInput(in.uv, in.position.xy, color, depth_at(in.uv)));
}
";
/// What the renderer gives the material prelude's LabPBR functions (`native_material.rs`); the
/// check and the entries below put a stand-in in its place.
macro_rules! pbr_stub {
() => {
"fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
return vec4<f32>(1.0);
}
fn horizon_block_size() -> vec2<f32> {
return vec2<f32>(16.0);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
fn horizon_block_pbr_present() -> bool {
return false;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(16.0);
}
"
};
}
pub const PBR_STUB: &str = pbr_stub!();
/// Entry points with the material stage's inputs: the renderer calls
/// `material` from its own terrain shader in the same way. The arguments are the line's
/// `MaterialInput` and return statements.
macro_rules! material_entries {
($input:literal, $result:literal) => {
concat!(
pbr_stub!(),
"\
struct HorizonMaterialIn {
@builtin(position) position: vec4<f32>,
@location(0) albedo: vec4<f32>,
@location(1) tint: vec4<f32>,
@location(2) light: vec2<f32>,
@location(3) lightmap: vec3<f32>,
@location(4) world: vec3<f32>,
@location(5) uv: vec2<f32>,
@location(6) @interpolate(flat) translucent: u32,
@location(7) @interpolate(flat) tags: u32,
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
@location(11) @interpolate(flat) material: u32,
}
@fragment fn horizon_fs_material(in: HorizonMaterialIn) -> @location(0) vec4<f32> {
let fluid = select(0u, select(2u, 1u, in.translucent != 0u), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.world);
let tangent = horizon_material_tangent(in.world, in.uv, normal);
",
$input,
$result,
"}
"
)
};
}
const MATERIAL_ENTRIES: &str = material_entries!(
" let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3<f32>(0.0), 0.0);
",
" return material(input).color;\n"
);
/// 0.2's: the colours decoded on the way in, and the result encoded for the frame
/// (`[frame] blend = "gamma"`; under `"linear"` the renderer stores it as it is).
const MATERIAL_ENTRIES_0_2: &str = material_entries!(
" let input = MaterialInput(horizon_decode_rgba(in.albedo), horizon_decode_rgba(in.tint), in.light,
horizon_decode(in.lightmap), in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3<f32>(0.0), 0.0);
",
" return horizon_encode_rgba(material(input).color);\n"
);
/// Entry points with the sky stage's inputs: the renderer calls `sky` from
/// its own sky shader in the same way.
const SKY_ENTRIES: &str = "\
struct HorizonSkyIn {
@builtin(position) position: vec4<f32>,
@location(0) direction: vec3<f32>,
@location(1) color: vec4<f32>,
}
@fragment fn horizon_fs_sky(in: HorizonSkyIn) -> @location(0) vec4<f32> {
return sky(SkyInput(normalize(in.direction), in.color));
}
";
const SKY_ENTRIES_0_2: &str = "\
struct HorizonSkyIn {
@builtin(position) position: vec4<f32>,
@location(0) direction: vec3<f32>,
@location(1) color: vec4<f32>,
}
@fragment fn horizon_fs_sky(in: HorizonSkyIn) -> @location(0) vec4<f32> {
return horizon_encode_rgba(sky(SkyInput(normalize(in.direction), horizon_decode_rgba(in.color))));
}
";
/// An entry point with the weather stage's inputs: the renderer calls `weather` from its own
/// weather shader in the same way.
const VERTEX_ENTRIES: &str = "\
@vertex fn horizon_vs_vertex(
@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) light: vec2<f32>,
@location(3) tint: vec4<f32>, @location(4) material: u32, @location(5) flags: u32,
) -> @builtin(position) vec4<f32> {
let input = VertexInput(position, uv, material, light, tint, flags & 7u, flags >> 31u);
return frame.projection * frame.view * vec4<f32>(vertex(input), 1.0);
}
";
/// 0.2's: the tint is a colour, so it is decoded too.
const VERTEX_ENTRIES_0_2: &str = "\
@vertex fn horizon_vs_vertex(
@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) light: vec2<f32>,
@location(3) tint: vec4<f32>, @location(4) material: u32, @location(5) flags: u32,
) -> @builtin(position) vec4<f32> {
let input = VertexInput(position, uv, material, light, horizon_decode_rgba(tint), flags & 7u, flags >> 31u);
return frame.projection * frame.view * vec4<f32>(vertex(input), 1.0);
}
";
/// `fn displacement(input: DisplacementInput) -> f32`, the material stage's function
/// under `[displacement]`.
pub const DISPLACEMENT_SIGNATURE: &str = "fn displacement(input: DisplacementInput) -> f32";
/// An entry point calling the material stage's `displacement`, after its entries under
/// `[displacement]`: the renderer calls it from its own terrain vertex shader in the same way.
const DISPLACEMENT_ENTRIES: &str = "@vertex fn horizon_vs_displacement(
@location(0) position: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>,
@location(3) material: u32, @location(4) height: f32,
) -> @builtin(position) vec4<f32> {
let depth = displacement(DisplacementInput(position, normal, uv, material, height));
return frame.projection * frame.view * vec4<f32>(position - normal * depth, 1.0);
}
";
const WEATHER_ENTRIES: &str = "\
struct HorizonWeatherIn {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) light: vec2<f32>,
@location(2) world: vec3<f32>,
@location(3) @interpolate(flat) snow: u32,
}
@fragment fn horizon_fs_weather(in: HorizonWeatherIn) -> @location(0) vec4<f32> {
return weather(WeatherInput(in.color, in.light, in.world, in.snow != 0u));
}
";
const WEATHER_ENTRIES_0_2: &str = "\
struct HorizonWeatherIn {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) light: vec2<f32>,
@location(2) world: vec3<f32>,
@location(3) @interpolate(flat) snow: u32,
}
@fragment fn horizon_fs_weather(in: HorizonWeatherIn) -> @location(0) vec4<f32> {
let input = WeatherInput(horizon_decode_rgba(in.color), in.light, in.world, in.snow != 0u);
return horizon_encode_rgba(weather(input));
}
";
/// The post stage's entry points under 0.2: the chain's targets hold linear light, so nothing
/// converts, and the pass's alpha is ignored.
const POST_ENTRIES_0_2: &str = "\
struct HorizonPostOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_post(@builtin(vertex_index) i: u32) -> HorizonPostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonPostOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_post(in: HorizonPostOut) -> @location(0) vec4<f32> {
let color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0);
return vec4<f32>(post(PostInput(in.uv, in.position.xy, color, depth_at(in.uv))).rgb, 1.0);
}
";
/// The tonemap stage's entry points: a full-screen triangle at the window's size, and a fragment
/// that holds the result to what the display pass takes.
const TONEMAP_ENTRIES: &str = "\
struct HorizonTonemapOut {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_vs_tonemap(@builtin(vertex_index) i: u32) -> HorizonTonemapOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: HorizonTonemapOut;
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
@fragment fn horizon_fs_tonemap(in: HorizonTonemapOut) -> @location(0) vec4<f32> {
let color = horizon_sanitize(textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb);
let out = tonemap(TonemapInput(in.uv, in.position.xy, color));
return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);
}
";
impl Api {
/// The stages the line has, in `StageKind::ALL`'s order.
pub fn stages(self) -> &'static [StageKind] {
match self {
Api::V0_1 => &StageKind::ALL[..5],
Api::V0_2 => &StageKind::ALL,
}
}
}
impl StageKind {
pub const ALL: [StageKind; 6] = [
StageKind::Sky,
StageKind::Vertex,
StageKind::Material,
StageKind::Weather,
StageKind::Post,
StageKind::Tonemap,
];
/// The manifest's name for the stage (`[sky]`, `[vertex]`, `[material]`, `[weather]`,
/// `[[post]]`, `[tonemap]`).
pub fn name(self) -> &'static str {
match self {
StageKind::Sky => "sky",
StageKind::Material => "material",
StageKind::Vertex => "vertex",
StageKind::Weather => "weather",
StageKind::Post => "post",
StageKind::Tonemap => "tonemap",
}
}
/// The function a pack defines: it has the stage's name.
pub fn function(self) -> &'static str {
self.name()
}
pub fn input(self) -> &'static Struct {
match self {
StageKind::Sky => &SKY_INPUT,
StageKind::Material => &MATERIAL_INPUT,
StageKind::Vertex => &VERTEX_INPUT,
StageKind::Weather => &WEATHER_INPUT,
StageKind::Post => &POST_INPUT,
StageKind::Tonemap => &TONEMAP_INPUT,
}
}
/// The type the function returns.
pub fn output(self) -> &'static str {
match self {
StageKind::Material => SURFACE.name,
StageKind::Vertex | StageKind::Tonemap => "vec3<f32>",
StageKind::Sky | StageKind::Weather | StageKind::Post => "vec4<f32>",
}
}
/// `fn <name>(input: <Input>) -> <Output>`.
pub fn signature(self) -> String {
format!(
"fn {}(input: {}) -> {}",
self.function(),
self.input().name,
self.output()
)
}
pub fn resources(self) -> &'static [Resource] {
match self {
StageKind::Post => &POST_RESOURCES,
StageKind::Sky | StageKind::Vertex | StageKind::Weather => std::slice::from_ref(&FRAME),
StageKind::Material => &MATERIAL_RESOURCES,
StageKind::Tonemap => &TONEMAP_RESOURCES,
}
}
/// Everything the pack's files may use under the line `api`, declared before them.
pub fn prelude(self, api: Api) -> String {
let mut out = match api {
Api::V0_1 => {
String::from("// Horizon shader API prelude (docs/api/shaders-reference.md)\n")
}
Api::V0_2 => String::from(
"// Horizon shader API 0.2 prelude (docs/api/shaders-reference-0.2.md)\n",
),
};
out += &Frame::line(api).wgsl();
out += &self.input().wgsl();
if self == StageKind::Material {
out += &SURFACE.wgsl();
// The vertex stage's file joins the material's module (`compose`).
out += &VERTEX_INPUT.wgsl();
out += &DISPLACEMENT_INPUT.wgsl();
}
for r in self.resources() {
out += &format!("@group({GROUP}) @binding({}) {};\n", r.binding, r.ty);
}
if api == Api::V0_2 {
out += COLOR_HELPERS;
}
match self {
StageKind::Post => {
out += POST_HELPERS;
out += POST_SURFACE;
out += SHADOW_HELPERS;
}
StageKind::Material => {
out += match api {
Api::V0_1 => MATERIAL_HELPERS,
Api::V0_2 => MATERIAL_HELPERS_0_2,
};
out += SHADOW_HELPERS;
}
StageKind::Vertex => out += SHADOW_HELPERS,
StageKind::Sky | StageKind::Weather | StageKind::Tonemap => {}
}
out
}
/// The entry points put after the pack's files under the line `api`.
pub fn entries(self, api: Api) -> &'static str {
match (api, self) {
(Api::V0_1, StageKind::Sky) => SKY_ENTRIES,
(Api::V0_1, StageKind::Material) => MATERIAL_ENTRIES,
(Api::V0_1, StageKind::Vertex) => VERTEX_ENTRIES,
(Api::V0_1, StageKind::Weather) => WEATHER_ENTRIES,
(Api::V0_1, StageKind::Post) => POST_ENTRIES,
(Api::V0_2, StageKind::Sky) => SKY_ENTRIES_0_2,
(Api::V0_2, StageKind::Material) => MATERIAL_ENTRIES_0_2,
(Api::V0_2, StageKind::Vertex) => VERTEX_ENTRIES_0_2,
(Api::V0_2, StageKind::Weather) => WEATHER_ENTRIES_0_2,
(Api::V0_2, StageKind::Post) => POST_ENTRIES_0_2,
// 0.1 has no tonemap stage: a manifest with one is refused before it is built.
(_, StageKind::Tonemap) => TONEMAP_ENTRIES,
}
}
}
/// The first binding of a pack's `[[textures]]` in group 3; their samplers follow at
/// `TEXTURE_SAMPLER_BINDING`.
pub const TEXTURE_BINDING: u32 = 16;
pub const TEXTURE_SAMPLER_BINDING: u32 = 24;
/// The declarations of a pack's `[[textures]]`, part of every stage's prelude.
pub fn texture_declarations(textures: &[crate::manifest::PackTexture]) -> String {
let mut out = String::new();
@@ -832,9 +45,6 @@ impl Composed {
}
}
/// The binding of a pack's first post target (`[[post]] output`) in group 3, the others after.
pub const POST_TARGET_BINDING: u32 = 32;
/// The declarations of a pack's post targets, part of the post stage's prelude.
pub fn post_target_declarations(manifest: &crate::manifest::Manifest) -> String {
manifest
@@ -913,10 +123,3 @@ pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
}
composed
}
/// Whether a material stage's `source` (with its prelude) calls `set_surface` itself, and so
/// needs the surface target, which a pack without it doesn't pay for.
pub fn calls_set_surface(source: &str) -> bool {
let call = "set_surface(";
source.matches(call).count() > MATERIAL_HELPERS.matches(call).count()
}
+2
View File
@@ -8,6 +8,8 @@ description = "Shader packs (the shaders/ folder format): reading packs, their .
[dependencies]
horizon-core = { path = "../horizon-core" }
# The pipeline description this crate fills (engine).
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in"] }
half = "2"
png = "0.18"
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more