MONKEY: lighting round 6 — Advanced Graphics options page, moon shadows, ground-effect spell lights
- Advanced Graphics page: Lighting Quality preset (Off/Low/Medium/High/Custom, one const table, Off = the original client's look), shadow rows moved from Graphics, Shadow Resolution, Moon Shadows, interior/torch/fire/spell/night rows; EN+RU strings; works in player builds. - Moon shadows: the single shadow rig re-aims at the moon at night (MoonShadowStrength 0.35); attenuates only the night sky term, never point lights; covers terrain, WMO exteriors, models and retained doodads; stateful hand-over for clock jumps; blob gated on a built silhouette; zero cost at strength 0. Signed fog_params.z carries +sun / -moon*strength. - Area spell lights: SpellLightMode::Area for Flamestrike, Rain of Fire, Consecration, Flare, Explosive Trap (school-based where the model says nothing); Blizzard/Hurricane/Volley/frost traps stay dark; repeating impacts deduped; benilla-extract spellvis prints the area block. 1121 world+formats tests; 8/8 WGSL probes; UI and world smoke scenes clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
839bc58d1b
commit
277deac06b
@@ -329,9 +329,12 @@
|
||||
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
|
||||
-- name suffixes; the visible label doubles as the page title (the era does the same —
|
||||
-- Settings.RegisterCategory names both from one string).
|
||||
-- MONKEY (advanced graphics): "AdvancedGraphics" joins the System group under Graphics —
|
||||
-- the seat the era's own tree gives a page that refines the one above it, and the seat
|
||||
-- that keeps the three shadow rows it inherits one row away from where they used to be.
|
||||
OPTIONS_CATEGORY_KEYS = {
|
||||
"Controls", "Interface", "ActionBars", "Combat", "Chat", "Keybindings", "Nameplates",
|
||||
"Graphics", "Audio",
|
||||
"Graphics", "AdvancedGraphics", "Audio",
|
||||
}
|
||||
|
||||
-- The era-window scale (shared, director-owned — the 0219 WORLDMAP_WINDOWED_SCALE
|
||||
@@ -804,19 +807,265 @@
|
||||
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
|
||||
.. "finished image instead, so it works in both display modes."
|
||||
|
||||
BENILLA_TOOLTIP_CHARACTER_SHADOWS =
|
||||
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
|
||||
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
|
||||
.. "additional GPU time and memory."
|
||||
-- ══ MONKEY (advanced graphics): the Advanced Graphics page's own strings, EN + RU ═════
|
||||
--
|
||||
-- The page that gives a PLAYER the dynamic light and shadow system. Until it existed the
|
||||
-- lanes were reachable only from `/console` and the native debug panel — and the panel
|
||||
-- does not ship in a player build, so a player had no way in at all. Three rows move here
|
||||
-- off the Graphics page (Real Character Shadows, World Shadows, Shadow Distance) with
|
||||
-- their settings and their descriptions unchanged; the other twelve are new seats on
|
||||
-- CVars this client has read for some time.
|
||||
--
|
||||
-- **Every string on it is authored in both languages** (the project's own standing rule),
|
||||
-- picked by `GetLocale()`, and there is no other localisation pattern in this window to
|
||||
-- follow: every page above is 1.12 GlobalStrings, whose own file IS the localisation. So
|
||||
-- the table is here, next to the page, rather than in a scheme of its own.
|
||||
--
|
||||
-- TWO CAVEATS, both true of the client and not of this table: benilla's `GetLocale()`
|
||||
-- answers `enUS` unconditionally today (it ships and reads enUS data only), so the ruRU
|
||||
-- column is dormant until that changes; and 1.12's own `FRIZQT__.TTF` carries no Cyrillic
|
||||
-- glyphs, so a Russian build needs a font that does before these read as words. Neither is
|
||||
-- a reason to ship the page half-authored — both are one line away from being true, and a
|
||||
-- string written later is a string written to a different standard.
|
||||
--
|
||||
-- The VALUES the rows write are never localised: a dropdown entry carries the engine's own
|
||||
-- string in `value` and the player's language in `text`, so `config.toml` and `/console`
|
||||
-- read the same words on every install.
|
||||
BENILLA_ADVGFX_STRINGS = {
|
||||
enUS = {
|
||||
page = "Advanced Graphics",
|
||||
lightingQuality = "Lighting Quality",
|
||||
qOff = "Off",
|
||||
qLow = "Low",
|
||||
qMedium = "Medium",
|
||||
qHigh = "High",
|
||||
qCustom = "Custom",
|
||||
characterShadows = "Real Character Shadows",
|
||||
worldShadows = "World Shadows",
|
||||
shadowDistance = "Shadow Distance",
|
||||
shadowResolution = "Shadow Resolution",
|
||||
moonShadows = "Moon Shadows",
|
||||
interiorLight = "Dynamic Interior Lighting",
|
||||
interiorShadows = "Indoor Torch Shadows",
|
||||
exteriorShadows = "Outdoor Torch Shadows",
|
||||
torchSoft = "Torch Shadow Softness",
|
||||
fireLight = "Fire Light Brightness",
|
||||
fireFlicker = "Fire Flicker",
|
||||
spellLights = "Spell Lights",
|
||||
nightDarkness = "Night Darkness",
|
||||
interiorDarkness = "Interior Darkness",
|
||||
yards = "%d yd",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
"Sets character, world, indoor torch and outdoor torch shadows; dynamic "
|
||||
.. "interior lighting; moon shadows; fire and spell brightness; fire flicker; "
|
||||
.. "and night and interior darkness. Low, Medium and High also set shadow "
|
||||
.. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
|
||||
.. "Off restores the original client's look: no synthesised fire lights, "
|
||||
.. "night gain 1.0 and interior gain 1.0. All other settings, including "
|
||||
.. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
|
||||
.. "Changing a preset member shows Custom when no preset matches.",
|
||||
CHARACTER_SHADOWS =
|
||||
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
|
||||
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
|
||||
.. "additional GPU time and memory.",
|
||||
WORLD_SHADOWS =
|
||||
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
|
||||
.. "following the sun, and switches off the baked terrain shadows they replace. "
|
||||
.. "Independent of Real Character Shadows. This uses additional GPU time and memory.",
|
||||
SHADOW_DISTANCE =
|
||||
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
|
||||
.. "the shadow map thinner (softer edges) and costs more. Applies on Apply.",
|
||||
SHADOW_RESOLUTION =
|
||||
"The edge of the sun's shadow map, in texels. Larger is sharper "
|
||||
.. "everywhere the shadow distance reaches.\n\n"
|
||||
.. "The cost is quadratic: 4096 draws four times the shadow pass of 2048 "
|
||||
.. "and holds four times the depth texture.",
|
||||
MOON_SHADOWS =
|
||||
"How dark a shadow thrown by moonlight is allowed to get. A little keeps "
|
||||
.. "the night from reading flat; a lot is stylised moonlight the original "
|
||||
.. "client never had.\n\n"
|
||||
.. "At 0 the moon casts nothing at all and the night renders exactly as "
|
||||
.. "it did before this existed. It costs nothing by day.",
|
||||
INTERIOR_LIGHT =
|
||||
"Lights the inside of buildings from the lamps, candles and hearths that "
|
||||
.. "actually stand in the room, instead of the lighting baked into the "
|
||||
.. "walls.\n\n"
|
||||
.. "Off restores the original client's baked interiors. On, it costs a "
|
||||
.. "little GPU time per lit surface. Indoor Torch Shadows require it; "
|
||||
.. "Outdoor Torch Shadows work independently.",
|
||||
INTERIOR_SHADOWS =
|
||||
"Lets the nearest indoor fires cast real shadows — a candle throws the "
|
||||
.. "table's shadow across the floor.\n\n"
|
||||
.. "Each promoted fire costs a shadow map of its own, which makes this "
|
||||
.. "the most expensive row on the page. Needs Dynamic Interior Lighting.",
|
||||
EXTERIOR_SHADOWS =
|
||||
"The same for fires out in the open: campfires, braziers and lampposts "
|
||||
.. "cast real shadows after dark.\n\n"
|
||||
.. "It does nothing by day and does not require Dynamic Interior Lighting.",
|
||||
TORCH_SOFT =
|
||||
"How soft the edge of a torch shadow is where the object meets the "
|
||||
.. "ground. Low is hard and crisp; high spreads the edge the way a real "
|
||||
.. "flame does.\n\n"
|
||||
.. "It costs no extra shadow maps — only a wider filter over the ones "
|
||||
.. "already drawn.",
|
||||
FIRE_LIGHT =
|
||||
"How brightly campfires, torches, braziers and forges light the world "
|
||||
.. "around them. These are lights benilla invents from the flame itself; "
|
||||
.. "the original client's art carries none.\n\n"
|
||||
.. "At 0 the invented fire lights are off and only the lights the artists "
|
||||
.. "placed remain. Moving it costs nothing.",
|
||||
FIRE_FLICKER =
|
||||
"How strongly every flame's light breathes — candles quick and shallow, "
|
||||
.. "bonfires slow and steady.\n\n"
|
||||
.. "At 0 fires burn at a constant brightness, which is what they did "
|
||||
.. "before this existed. It is computed on the processor and costs the "
|
||||
.. "graphics card nothing.",
|
||||
SPELL_LIGHTS =
|
||||
"How brightly spells, missiles and impacts light the room around them — a "
|
||||
.. "fireball's flash, an aura's glow.\n\n"
|
||||
.. "Separate from Fire Light Brightness on purpose: calming a busy fight "
|
||||
.. "should not dim the world's hearths. At 0 no spell throws light.",
|
||||
NIGHT_DARKNESS =
|
||||
"How dark the world gets outdoors after sunset. "
|
||||
.. "1.0 = the original client night; lower = darker.\n\n"
|
||||
.. "It never touches the fires — the point is a darker night around the "
|
||||
.. "flame, not a dimmer flame. Inert while the sun is up, and free at any "
|
||||
.. "setting.",
|
||||
INTERIOR_DARKNESS =
|
||||
"Scales interior fixture light and room ambient, including the light from "
|
||||
.. "lamps and candles. Lower values make both dimmer.\n\n"
|
||||
.. "1.0 leaves these inputs unchanged. Free at any setting.",
|
||||
},
|
||||
},
|
||||
ruRU = {
|
||||
page = "Расширенная графика",
|
||||
lightingQuality = "Качество освещения",
|
||||
qOff = "Выкл.",
|
||||
qLow = "Низкое",
|
||||
qMedium = "Среднее",
|
||||
qHigh = "Высокое",
|
||||
qCustom = "Своё",
|
||||
characterShadows = "Настоящие тени персонажей",
|
||||
worldShadows = "Тени мира",
|
||||
shadowDistance = "Дальность теней",
|
||||
shadowResolution = "Разрешение теней",
|
||||
moonShadows = "Лунные тени",
|
||||
interiorLight = "Динамическое освещение помещений",
|
||||
interiorShadows = "Тени факелов в помещениях",
|
||||
exteriorShadows = "Тени огней под открытым небом",
|
||||
torchSoft = "Мягкость теней от огня",
|
||||
fireLight = "Яркость огня",
|
||||
fireFlicker = "Мерцание огня",
|
||||
spellLights = "Свет от заклинаний",
|
||||
nightDarkness = "Темнота ночи",
|
||||
interiorDarkness = "Темнота помещений",
|
||||
yards = "%d ярд.",
|
||||
tips = {
|
||||
LIGHTING_QUALITY =
|
||||
"Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
|
||||
.. "динамическое освещение помещений; лунные тени; яркость огня и заклинаний; "
|
||||
.. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» "
|
||||
.. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней "
|
||||
.. "от огней и число огней с тенями движущихся объектов.\n\n"
|
||||
.. "«Выкл.» возвращает вид оригинального клиента: без созданных из пламени "
|
||||
.. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
|
||||
.. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
|
||||
.. "сохраняют ваши значения. Изменение настройки набора показывает «Своё», "
|
||||
.. "если ни один набор больше не совпадает.",
|
||||
CHARACTER_SHADOWS =
|
||||
"Рисует настоящие динамические силуэты игроков, НИП, существ и ездовых "
|
||||
.. "животных вместо старого овального пятна. Соответствует уровню теней "
|
||||
.. "персонажей из Wrath. Требует дополнительного времени и памяти "
|
||||
.. "видеокарты.",
|
||||
WORLD_SHADOWS =
|
||||
"Заставляет статичный мир — деревья, здания и растительность — "
|
||||
.. "отбрасывать настоящие динамические тени вслед за солнцем и отключает "
|
||||
.. "запечённые тени ландшафта, которые они заменяют. Не зависит от "
|
||||
.. "настоящих теней персонажей. Требует дополнительного времени и памяти "
|
||||
.. "видеокарты.",
|
||||
SHADOW_DISTANCE =
|
||||
"Как далеко рисуются тени в реальном времени, в ярдах. Большее значение "
|
||||
.. "покрывает больше земли, но растягивает карту теней тоньше (края "
|
||||
.. "становятся мягче) и стоит дороже. Применяется по кнопке «Применить».",
|
||||
SHADOW_RESOLUTION =
|
||||
"Размер карты теней солнца в текселях. Чем больше, тем чётче тени на "
|
||||
.. "всей дальности их прорисовки.\n\n"
|
||||
.. "Стоимость растёт квадратично: 4096 — это вчетверо больше работы в "
|
||||
.. "проходе теней и вчетверо больше видеопамяти под карту глубины, чем "
|
||||
.. "2048.",
|
||||
MOON_SHADOWS =
|
||||
"Насколько тёмной может стать тень, отбрасываемая лунным светом. "
|
||||
.. "Немного — и ночь перестаёт выглядеть плоской; много — это "
|
||||
.. "стилизованная луна, какой в оригинальном клиенте не было.\n\n"
|
||||
.. "При 0 луна не отбрасывает теней вовсе, и ночь выглядит так же, как "
|
||||
.. "до появления этой возможности. Днём не стоит ничего.",
|
||||
INTERIOR_LIGHT =
|
||||
"Освещает помещения от ламп, свечей и очагов, которые действительно "
|
||||
.. "стоят в комнате, а не от освещения, запечённого в стенах.\n\n"
|
||||
.. "«Выкл.» возвращает запечённые интерьеры оригинального клиента. "
|
||||
.. "Включённый режим стоит немного времени видеокарты на каждую "
|
||||
.. "освещённую поверхность. Тени от огней внутри требуют этого режима; "
|
||||
.. "тени от огней снаружи работают независимо.",
|
||||
INTERIOR_SHADOWS =
|
||||
"Позволяет ближайшим огням в помещении отбрасывать настоящие тени: "
|
||||
.. "свеча кладёт тень стола на пол.\n\n"
|
||||
.. "Каждый такой огонь стоит собственной карты теней, поэтому это самая "
|
||||
.. "дорогая строка на странице. Требует динамического освещения "
|
||||
.. "помещений.",
|
||||
EXTERIOR_SHADOWS =
|
||||
"То же для огней под открытым небом: костры, жаровни и фонари "
|
||||
.. "отбрасывают настоящие тени после наступления темноты.\n\n"
|
||||
.. "Днём не действует и не требует динамического освещения помещений.",
|
||||
TORCH_SOFT =
|
||||
"Насколько мягок край тени от огня там, где предмет касается пола. "
|
||||
.. "Низкое значение — резкий край, высокое — размытый, как от "
|
||||
.. "настоящего пламени.\n\n"
|
||||
.. "Дополнительных карт теней не требует: это лишь более широкий фильтр "
|
||||
.. "по уже нарисованным.",
|
||||
FIRE_LIGHT =
|
||||
"Насколько ярко костры, факелы, жаровни и горны освещают всё вокруг. "
|
||||
.. "Эти источники benilla создаёт из самого пламени — в оригинальной "
|
||||
.. "графике их нет.\n\n"
|
||||
.. "При 0 придуманные огни выключены и остаются лишь источники, "
|
||||
.. "расставленные художниками. Изменение значения ничего не стоит.",
|
||||
FIRE_FLICKER =
|
||||
"Насколько сильно «дышит» свет каждого пламени: свечи — часто и слабо, "
|
||||
.. "костры — медленно и ровно.\n\n"
|
||||
.. "При 0 огни горят с постоянной яркостью, как было до появления этой "
|
||||
.. "возможности. Считается на процессоре и ничего не стоит видеокарте.",
|
||||
SPELL_LIGHTS =
|
||||
"Насколько ярко заклинания, снаряды и попадания освещают всё вокруг: "
|
||||
.. "вспышка огненного шара, свечение ауры.\n\n"
|
||||
.. "Отделено от яркости огня намеренно: успокоить шумный бой не должно "
|
||||
.. "значить пригасить очаги во всём мире. При 0 заклинания не дают "
|
||||
.. "света.",
|
||||
NIGHT_DARKNESS =
|
||||
"Насколько темно становится под открытым небом после заката. "
|
||||
.. "1.0 — ночь оригинального клиента; ниже — темнее.\n\n"
|
||||
.. "Огней не касается: смысл в том, чтобы ночь вокруг пламени была "
|
||||
.. "темнее, а не само пламя тусклее. Пока солнце в небе, не действует, и "
|
||||
.. "ничего не стоит при любом значении.",
|
||||
INTERIOR_DARKNESS =
|
||||
"Масштабирует свет светильников и фоновое освещение помещений, включая "
|
||||
.. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n"
|
||||
.. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
BENILLA_TOOLTIP_WORLD_SHADOWS =
|
||||
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
|
||||
.. "following the sun, and switches off the baked terrain shadows they replace. "
|
||||
.. "Independent of Real Character Shadows. This uses additional GPU time and memory."
|
||||
-- The selected column. `or` the English one so an unbuilt locale is a fallback rather
|
||||
-- than fifteen rows of `nil` labels — 1.12's own GlobalStrings do the same thing by
|
||||
-- shipping one file per locale with every key present.
|
||||
BENILLA_ADVGFX = BENILLA_ADVGFX_STRINGS[GetLocale()] or BENILLA_ADVGFX_STRINGS.enUS
|
||||
|
||||
BENILLA_TOOLTIP_SHADOW_DISTANCE =
|
||||
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
|
||||
.. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply."
|
||||
-- A row's description resolves by GLOBAL NAME at hover time (`OptionsRow_ShowTooltip`), so
|
||||
-- the selected column's copy is published under the key the row carries. Same `BENILLA_`
|
||||
-- prefix the four rows above use, and for the same reason: the reference's
|
||||
-- `OPTION_TOOLTIP_` namespace stays the reference's.
|
||||
for key, text in pairs(BENILLA_ADVGFX.tips) do
|
||||
setglobal("BENILLA_TOOLTIP_" .. key, text)
|
||||
end
|
||||
|
||||
OPTIONS_PAGE_ROWS = {
|
||||
-- Seven rows became sixteen at 2180, and the nine that arrived were not new
|
||||
@@ -851,9 +1100,25 @@
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
|
||||
"RowWeatherDensity",
|
||||
"RowDisplayMode", "RowVerticalSync",
|
||||
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
|
||||
"RowRenderScale",
|
||||
},
|
||||
-- MONKEY (advanced graphics): the dynamic light + shadow system's own page. Order is
|
||||
-- the preset first (it writes the rest), then the SUN lanes, then the ROOM lane and
|
||||
-- its torches, then the invented lights, then the two darkness dials — coarse to
|
||||
-- fine, and each group's own master above the rows it gates.
|
||||
--
|
||||
-- Real Character Shadows, World Shadows and Shadow Distance MOVED here from Graphics,
|
||||
-- carrying their CVars and their descriptions unchanged: they belong with the rest of
|
||||
-- the rig they share, and the Graphics page had become the place where a player found
|
||||
-- three of eighteen shadow settings and no hint that the other fifteen existed.
|
||||
AdvancedGraphics = {
|
||||
"RowLightingQuality",
|
||||
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
|
||||
"RowShadowResolution", "RowMoonShadows",
|
||||
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
|
||||
"RowFireLight", "RowFireFlicker", "RowSpellLights",
|
||||
"RowNightDarkness", "RowInteriorDarkness",
|
||||
},
|
||||
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
|
||||
-- reference's own relationship twice over: its CheckButton22 hangs off 21 with a +26
|
||||
-- x indent, and UIOptionsFrame_UpdateDependencies l.702-708 greys 22 (and 23) from
|
||||
@@ -1030,7 +1295,7 @@
|
||||
-- The write. A global's consumers are Lua and do not poll, so a uvar row also runs its
|
||||
-- `applyFunc` — the reference's own per-uvar side effects (SIMPLE_CHAT → FCF_Set_*,
|
||||
-- HIDE_PARTY_INTERFACE → RaidOptionsFrame_UpdatePartyFrames, the combat-text family →
|
||||
-- CombatText_UpdateDisplayedMessages), moved from its batched Okay to our live write
|
||||
-- CombatText_UpdateDisplayedMessages), moved from its batched confirmation to our live write
|
||||
-- because this window commits on the click (0961's grammar), not on a Save button.
|
||||
-- `cvarEvent` is a CVar row's counterpart to a global row's `applyFunc`, and it is the
|
||||
-- reference's own third SetCVar argument (decision 1140): the token the engine hands back
|
||||
@@ -1368,11 +1633,33 @@
|
||||
return string.format("%d%%",
|
||||
math.floor((value - row.minv) / (row.maxv - row.minv) * 100 + 0.5))
|
||||
end
|
||||
-- MONKEY (advanced graphics): travel, READ BACKWARDS — the same share of the groove,
|
||||
-- counted from the right-hand end. The two darkness dials are the case: their stored
|
||||
-- unit is a GAIN (`nightGain`, `interiorGain`), so the darkest setting is the SMALLEST
|
||||
-- number and sits at the left of the groove, while the row is named for the darkness
|
||||
-- rather than the gain. Without this the readout would count UP as the world got
|
||||
-- LIGHTER under a label that says "Darkness", which is exactly backwards; with it the
|
||||
-- label, the thumb and the number all agree that further left is darker. The store
|
||||
-- keeps the gain, unchanged — this is a readout, not a unit.
|
||||
if row.fmt == "darkness" and row.minv and row.maxv and row.maxv > row.minv then
|
||||
return string.format("%d%%",
|
||||
math.floor((row.maxv - value) / (row.maxv - row.minv) * 100 + 0.5))
|
||||
end
|
||||
-- A row whose stored value is a FACTOR but whose meaning is a distance: show the
|
||||
-- distance (decision 1140 — cameraDistanceMaxFactor over the 15 yd base). The number
|
||||
-- the player is choosing is "how far out the wheel stops", not "times 1.4".
|
||||
if row.fmt == "yards" then
|
||||
return string.format("%d yd", math.floor(value * 15 + 0.5))
|
||||
return string.format(BENILLA_ADVGFX.yards, math.floor(value * 15 + 0.5))
|
||||
end
|
||||
-- MONKEY (advanced graphics): a row whose stored value ALREADY IS a distance in yards
|
||||
-- — the realtime-shadow range. Its own format, because the one above it is a
|
||||
-- CONVERSION and not a unit: `shadowDistance` rode `"yards"` from the day it was
|
||||
-- seated, so an 80-yard cascade printed "1200 yd" and nothing caught it (no test read
|
||||
-- that readout, and 1200 is not an absurd-looking number for a view distance). The
|
||||
-- two formats differ by exactly the factor's 15 yd base, which is why they cannot be
|
||||
-- one.
|
||||
if row.fmt == "yd" then
|
||||
return string.format(BENILLA_ADVGFX.yards, math.floor(value + 0.5))
|
||||
end
|
||||
return string.format("%d%%", math.floor(value * 100 + 0.5))
|
||||
end
|
||||
@@ -1533,6 +1820,47 @@
|
||||
OptionsApply_Update()
|
||||
end
|
||||
|
||||
-- ── MONKEY (advanced graphics): the page that has to WATCH its settings ─────────────
|
||||
-- Every other page in this window owns its rows outright: nothing but a click on the page
|
||||
-- moves the CVars behind it while it is open, so reading them once on select is the whole
|
||||
-- contract. The lighting rows are not like that, and deliberately so — they are the live
|
||||
-- A/B knobs the whole system was tuned with, and there are three other ways to move one
|
||||
-- with this page on screen: `/console set nightGain 1` in chat, the native lighting panel
|
||||
-- in a dev build, and — the one that matters to a player — the Lighting Quality row above,
|
||||
-- which does not write its members here at all. It writes ONE CVar; the host's preset
|
||||
-- table answers by writing the other ten, and those land a frame or two later.
|
||||
--
|
||||
-- So the page polls. A cheap signature of its own rows' values, four times a second, and a
|
||||
-- refresh only when that string actually moves — which is never, on a page nobody is
|
||||
-- touching. The alternative (an event) does not exist: a host-side CVar write reaches the
|
||||
-- interface as a mirror update, not as `CVAR_UPDATE`, which the reference only ever fires
|
||||
-- for a script's own `SetCVar` with a token.
|
||||
--
|
||||
-- Safe under a drag, and not by luck: the value the refresh would write back is the value
|
||||
-- the drag just wrote, `SetValue` fires nothing when it does not change the value, and the
|
||||
-- `refreshing` guard stops a write even if it did. A staged (deferred) edit is safe too —
|
||||
-- `OptionsPage_Refresh` shows `row.pending` ahead of the stored value.
|
||||
BENILLA_ADVGFX_POLL = 0.25
|
||||
|
||||
function AdvGfx_Signature()
|
||||
local out = ""
|
||||
for _, rkey in ipairs(OPTIONS_PAGE_ROWS.AdvancedGraphics) do
|
||||
local row = getglobal("BenillaOptionsFrameContainerBodyAdvancedGraphics" .. rkey)
|
||||
out = out .. "|" .. (OptionsRow_Get(row) or "")
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function AdvGfx_OnUpdate(page, elapsed)
|
||||
page.since = (page.since or 0) + (elapsed or 0)
|
||||
if page.since < BENILLA_ADVGFX_POLL then return end
|
||||
page.since = 0
|
||||
local sig = AdvGfx_Signature()
|
||||
if sig == page.sig then return end
|
||||
page.sig = sig
|
||||
OptionsPage_Refresh("AdvancedGraphics")
|
||||
end
|
||||
|
||||
-- Per-page dependency rules. Audio's is the 1.12 rule, verbatim: Enable All Sound off
|
||||
-- greys Enable Ambience — and only it, of our rows
|
||||
-- (SoundOptionsFrame_UpdateDependencies disables buttons 2 and 4, Ambience and Error
|
||||
@@ -1575,6 +1903,27 @@
|
||||
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyCombatRowPetDamage, dmg)
|
||||
return
|
||||
end
|
||||
if key == "AdvancedGraphics" then
|
||||
-- MONKEY (advanced graphics): the page's own dependencies — the same shape the
|
||||
-- reference's own rules have (a master's own switch greys what it gates), read
|
||||
-- off the CVar docs rather than invented: `interiorShadows` "needs
|
||||
-- interiorLight", the two shadow-rig rows size a cascade that is not rendered
|
||||
-- with both sun lanes off, and the torch softness is the PCF radius on maps
|
||||
-- neither torch lane is drawing. The CVars keep their values throughout — this
|
||||
-- is a dependency, not a write, so turning a master back on finds its children
|
||||
-- exactly where they were left.
|
||||
local base = "BenillaOptionsFrameContainerBodyAdvancedGraphics"
|
||||
local sun = GetCVar("characterShadows") ~= "0"
|
||||
or GetCVar("worldShadows") ~= "0"
|
||||
OptionsRow_SetEnabled(getglobal(base .. "RowShadowDistance"), sun)
|
||||
OptionsRow_SetEnabled(getglobal(base .. "RowShadowResolution"), sun)
|
||||
local interior = GetCVar("interiorLight") ~= "0"
|
||||
OptionsRow_SetEnabled(getglobal(base .. "RowInteriorShadows"), interior)
|
||||
local torch = (interior and GetCVar("interiorShadows") ~= "0")
|
||||
or GetCVar("exteriorShadows") ~= "0"
|
||||
OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
|
||||
return
|
||||
end
|
||||
if key == "Controls" then
|
||||
-- 1.12's own rule (l.715-719): the Auto-Follow Speed slider is dead while the
|
||||
-- following style is Never, because there is no follow to set a speed for. The
|
||||
@@ -2346,8 +2695,16 @@
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentHeaderSystem" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Graphics", "Graphics")</OnLoad></Scripts>
|
||||
</Button>
|
||||
<Button name="$parentRowAudio" inherits="OptionsCategoryRowTemplate">
|
||||
<!-- MONKEY (advanced graphics): under Graphics, which is what it refines. Its
|
||||
label is the one category label in this tree that is not a literal — the
|
||||
page is bilingual, so the row (and the page title it doubles as) reads
|
||||
the selected column like every other string on it. -->
|
||||
<Button name="$parentRowAdvancedGraphics" inherits="OptionsCategoryRowTemplate">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowGraphics" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "AdvancedGraphics", BENILLA_ADVGFX.page)</OnLoad></Scripts>
|
||||
</Button>
|
||||
<Button name="$parentRowAudio" inherits="OptionsCategoryRowTemplate">
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentRowAdvancedGraphics" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>OptionsCategoryRow_OnLoad(self, "Audio", "Audio")</OnLoad></Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
@@ -3099,48 +3456,18 @@
|
||||
"OPTION_TOOLTIP_VERTICAL_SYNC")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<!-- MONKEY (advanced graphics): Real Character Shadows,
|
||||
World Shadows and Shadow Distance stood here between
|
||||
Vertical Sync and Render Scale. They are the Advanced
|
||||
Graphics page's now, unchanged — same CVars, same
|
||||
labels, same descriptions, same deferred commit on the
|
||||
slider — because they are three rows of one rig whose
|
||||
other fifteen a player could not reach at all. -->
|
||||
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
|
||||
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
|
||||
"BENILLA_TOOLTIP_WORLD_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Realtime-shadow render distance (yd): the cascade
|
||||
-- range + caster reach. 40-200, matching the CVar's
|
||||
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
|
||||
-- Apply so a drag doesn't re-fit the cascade + recollect
|
||||
-- casters on every tick (one rebuild on commit instead).
|
||||
OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance",
|
||||
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
|
||||
OptionsSliderRow_Setup(self, 40, 200, 10, "yards")
|
||||
self.deferred = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Render Scale (decision 1639) -- benilla's own
|
||||
-- row, no 1.12 counterpart: the reference renders
|
||||
@@ -3199,6 +3526,288 @@
|
||||
</OnShow>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- ══ MONKEY (advanced graphics): the ADVANCED GRAPHICS page ═══
|
||||
The dynamic light and shadow system, as a page a player can
|
||||
reach. Nothing here is a new mechanism: every row is a seat on
|
||||
a CVar this client already registers, reads per frame and
|
||||
persists, and the page is built out of the same three row
|
||||
templates and the same OptionsRow_* store as every page above
|
||||
it. What it adds is a way in — before it, a player build had
|
||||
none, because the native lighting panel is a dev-build window
|
||||
and `/console` is not a setting.
|
||||
|
||||
Order is coarse to fine, and each master sits above what it
|
||||
gates: the preset (which writes the rest), the sun rig, the
|
||||
room lane with its torches, the invented lights, the two
|
||||
darkness dials. The three rows that moved here from Graphics
|
||||
keep their CVars, labels, descriptions and — on Shadow
|
||||
Distance — the deferred commit, verbatim.
|
||||
|
||||
Every label and description is bilingual (BENILLA_ADVGFX);
|
||||
every VALUE written stays the engine's own string. -->
|
||||
<Frame name="$parentAdvancedGraphics" hidden="true">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
<Anchor point="BOTTOMRIGHT"/>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<Frame name="$parentRowLightingQuality" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- The preset ladder. This row writes ONE CVar and
|
||||
-- nothing else: the host's own table
|
||||
-- (`cvars::LIGHTING_PRESETS`) answers the write by
|
||||
-- setting the member rows, and re-derives this
|
||||
-- row's value from them every frame — which is why
|
||||
-- Custom is an entry the capsule can SHOW but
|
||||
-- picking it writes nothing anyone keeps.
|
||||
--
|
||||
-- The table is deliberately NOT here. Three other
|
||||
-- ways reach the same ladder (the console, the
|
||||
-- native lighting panel, a hand-edited
|
||||
-- config.toml), and a copy carried by this window
|
||||
-- would be a copy the other three cannot see.
|
||||
-- The page's OnUpdate poll is what brings the
|
||||
-- member rows' new values back onto the screen.
|
||||
OptionsRow_OnLoad(self, "lightingQuality",
|
||||
BENILLA_ADVGFX.lightingQuality,
|
||||
"BENILLA_TOOLTIP_LIGHTING_QUALITY")
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = BENILLA_ADVGFX.qOff, value = "Off" },
|
||||
{ text = BENILLA_ADVGFX.qLow, value = "Low" },
|
||||
{ text = BENILLA_ADVGFX.qMedium, value = "Medium" },
|
||||
{ text = BENILLA_ADVGFX.qHigh, value = "High" },
|
||||
{ text = BENILLA_ADVGFX.qCustom, value = "Custom" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowLightingQuality" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "characterShadows",
|
||||
BENILLA_ADVGFX.characterShadows,
|
||||
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "worldShadows",
|
||||
BENILLA_ADVGFX.worldShadows,
|
||||
"BENILLA_TOOLTIP_WORLD_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Realtime-shadow render distance (yd): the cascade
|
||||
-- range + caster reach. 40-200, matching the CVar's
|
||||
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
|
||||
-- Apply so a drag doesn't re-fit the cascade + recollect
|
||||
-- casters on every tick (one rebuild on commit instead).
|
||||
OptionsRow_OnLoad(self, "shadowDistance",
|
||||
BENILLA_ADVGFX.shadowDistance,
|
||||
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
|
||||
-- `"yd"`, not `"yards"`: the value is already a
|
||||
-- distance in yards, where the format beside it
|
||||
-- multiplies a FACTOR by the 15 yd camera base.
|
||||
-- This row rode the wrong one from the day it was
|
||||
-- seated and printed "1200 yd" for an 80 yd
|
||||
-- cascade.
|
||||
OptionsSliderRow_Setup(self, 40, 200, 10, "yd")
|
||||
self.deferred = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowShadowResolution" inherits="OptionsDropdownRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- The three stops the CVar's own snap accepts
|
||||
-- (`shadow_core::SHADOW_MAP_SIZES`): a dropdown
|
||||
-- rather than a slider because an off-ladder value
|
||||
-- is not a weaker setting, it is one the renderer
|
||||
-- rounds UP into a bigger and slower map than the
|
||||
-- one that was asked for. The entry texts are the
|
||||
-- numbers themselves — nothing to translate.
|
||||
OptionsRow_OnLoad(self, "shadowMapSize",
|
||||
BENILLA_ADVGFX.shadowResolution,
|
||||
"BENILLA_TOOLTIP_SHADOW_RESOLUTION")
|
||||
OptionsDropdownRow_Setup(self, {
|
||||
{ text = "1024", value = "1024" },
|
||||
{ text = "2048", value = "2048" },
|
||||
{ text = "4096", value = "4096" },
|
||||
})
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowMoonShadows" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowResolution" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowResolution" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 0..1, the CVar's own clamp, and 0 is the FAITHFUL
|
||||
-- null rather than a floor: every consumer guards
|
||||
-- on it, so the left end of this groove is the
|
||||
-- night the client rendered before the moon lane
|
||||
-- existed.
|
||||
OptionsRow_OnLoad(self, "moonShadowStrength",
|
||||
BENILLA_ADVGFX.moonShadows,
|
||||
"BENILLA_TOOLTIP_MOON_SHADOWS")
|
||||
OptionsSliderRow_Setup(self, 0, 1, 0.05)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowInteriorLight" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowMoonShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowMoonShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "interiorLight",
|
||||
BENILLA_ADVGFX.interiorLight,
|
||||
"BENILLA_TOOLTIP_INTERIOR_LIGHT")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowInteriorShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowInteriorLight" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowInteriorLight" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "interiorShadows",
|
||||
BENILLA_ADVGFX.interiorShadows,
|
||||
"BENILLA_TOOLTIP_INTERIOR_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowExteriorShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowInteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowInteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "exteriorShadows",
|
||||
BENILLA_ADVGFX.exteriorShadows,
|
||||
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowTorchSoft" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowExteriorShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 0.5..3, the CVar's own clamp verbatim: it is a
|
||||
-- SCALE on the PCF tap radius at a contact, so the
|
||||
-- readout is a percentage of the shipped radius
|
||||
-- and 100% is what the lane was tuned at.
|
||||
OptionsRow_OnLoad(self, "interiorShadowSoft",
|
||||
BENILLA_ADVGFX.torchSoft,
|
||||
"BENILLA_TOOLTIP_TORCH_SOFT")
|
||||
OptionsSliderRow_Setup(self, 0.5, 3, 0.1)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowFireLight" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowTorchSoft" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowTorchSoft" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Full 0..4 registry range, in five-percent steps.
|
||||
OptionsRow_OnLoad(self, "fireLightGain",
|
||||
BENILLA_ADVGFX.fireLight,
|
||||
"BENILLA_TOOLTIP_FIRE_LIGHT")
|
||||
OptionsSliderRow_Setup(self, 0, 4, 0.05)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowFireFlicker" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFireLight" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFireLight" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 0..2, the CVar's own clamp verbatim: the
|
||||
-- amplitudes are authored at 100%, and 200% is the
|
||||
-- deliberate over-drive for judging the shape.
|
||||
OptionsRow_OnLoad(self, "fireFlicker",
|
||||
BENILLA_ADVGFX.fireFlicker,
|
||||
"BENILLA_TOOLTIP_FIRE_FLICKER")
|
||||
OptionsSliderRow_Setup(self, 0, 2, 0.05)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowSpellLights" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowFireFlicker" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowFireFlicker" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- Full 0..4 registry range, in five-percent steps.
|
||||
OptionsRow_OnLoad(self, "spellLightGain",
|
||||
BENILLA_ADVGFX.spellLights,
|
||||
"BENILLA_TOOLTIP_SPELL_LIGHTS")
|
||||
OptionsSliderRow_Setup(self, 0, 4, 0.05)
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowNightDarkness" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowSpellLights" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- 0.2..1.5, the CVar's own clamp verbatim: the
|
||||
-- floor is 0.2 rather than 0 because a true 0 is
|
||||
-- indistinguishable from a broken light pack, and
|
||||
-- the "off" a player wants is the reference's own
|
||||
-- night at gain 1.
|
||||
--
|
||||
-- The readout is `darkness` — travel read from the
|
||||
-- right — because the stored unit is a GAIN while
|
||||
-- the row is named for the darkness: left is
|
||||
-- darker, and this is what makes the number say so
|
||||
-- too. The store keeps the gain, untouched.
|
||||
OptionsRow_OnLoad(self, "nightGain",
|
||||
BENILLA_ADVGFX.nightDarkness,
|
||||
"BENILLA_TOOLTIP_NIGHT_DARKNESS")
|
||||
OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowInteriorDarkness" inherits="OptionsSliderRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowNightDarkness" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowNightDarkness" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
-- The room lane's own dim dial, same clamp and same
|
||||
-- backwards readout as the night one above it.
|
||||
OptionsRow_OnLoad(self, "interiorGain",
|
||||
BENILLA_ADVGFX.interiorDarkness,
|
||||
"BENILLA_TOOLTIP_INTERIOR_DARKNESS")
|
||||
OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
|
||||
settings move from outside it — the Lighting Quality row
|
||||
writes its members through the host, and the console and
|
||||
the dev lighting panel reach the same CVars while the page
|
||||
is open. A signature compare four times a second, and a
|
||||
refresh only when it actually moves. -->
|
||||
<Scripts>
|
||||
<OnUpdate>AdvGfx_OnUpdate(this, arg1)</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- The COMBAT page (decision 1134) — the first page whose rows
|
||||
write saved-variable GLOBALS rather than CVars, and the first
|
||||
thing that can change any of the ~20 stock globals 1128
|
||||
@@ -4379,6 +4988,14 @@
|
||||
getglobal(self:GetName() .. "Label"):SetText("Graphics")
|
||||
</OnLoad></Scripts>
|
||||
</Button>
|
||||
<!-- MONKEY (advanced graphics): its own head, like every page with
|
||||
rows. The label is the page's own bilingual title. -->
|
||||
<Button name="$parentSearchHeadAdvancedGraphics" inherits="OptionsSearchHeadTemplate">
|
||||
<Scripts><OnLoad>
|
||||
self.categoryKey = "AdvancedGraphics"
|
||||
getglobal(self:GetName() .. "Label"):SetText(BENILLA_ADVGFX.page)
|
||||
</OnLoad></Scripts>
|
||||
</Button>
|
||||
<Button name="$parentSearchHeadCombat" inherits="OptionsSearchHeadTemplate">
|
||||
<Scripts><OnLoad>
|
||||
self.categoryKey = "Combat"
|
||||
|
||||
@@ -99,9 +99,12 @@ use bevy::prelude::*;
|
||||
use crate::creature_anim::AnimData;
|
||||
use crate::net::{Embodied, NetEntity};
|
||||
use benilla_world::decal::{DecalFrame, WorldDecal};
|
||||
use benilla_world::lighting::WowLighting;
|
||||
// MONKEY (moon shadows): the oval now yields to a NIGHT cast as well as a day one, so it needs the
|
||||
// same two numbers the packer weights that cast with.
|
||||
use benilla_world::lighting::ShadowHandover;
|
||||
use crate::character_shadow::CharacterShadowReady;
|
||||
use crate::shadow_core::{ShadowFrame, ShadowSet};
|
||||
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
|
||||
use benilla_world::schedule::WorldStage;
|
||||
use benilla_world::view::WorldCamera;
|
||||
use benilla_world::wmo_portal::UnitWmoRoom;
|
||||
|
||||
@@ -158,14 +161,16 @@ impl Plugin for BlobShadowPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_shadow_assets)
|
||||
.add_systems(
|
||||
Update,
|
||||
PostUpdate,
|
||||
(sync_shadows, update_shadows)
|
||||
.chain()
|
||||
// After net motion + input: the decal follows this frame's unit transforms.
|
||||
.after(WorldStage::Input),
|
||||
// MONKEY (moon shadows): after ALL Update work (resolve + strength bridge)
|
||||
// and this frame's rig verdict. Push only after the blob has consumed it.
|
||||
.after(ShadowSet::Rig)
|
||||
.after(bevy::transform::TransformSystems::Propagate)
|
||||
.before(push_shadows),
|
||||
)
|
||||
// The stream push: after the frame's stream clear (the caches were rebuilt in
|
||||
// `Update`, so this is a pure copy).
|
||||
// The stream push: after both the frame's stream clear and the blob cache rebuild.
|
||||
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
|
||||
}
|
||||
}
|
||||
@@ -241,10 +246,12 @@ fn update_shadows(
|
||||
shadow_assets: Option<Res<ShadowAssets>>,
|
||||
images: Res<Assets<Image>>,
|
||||
decals: WorldDecal,
|
||||
// MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME
|
||||
// `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's
|
||||
// crossfade and the realtime shadow's fade are two halves of one number.
|
||||
lighting: Res<WowLighting>,
|
||||
// MONKEY (moon shadows): SAME acknowledged weight as the packed receivers, plus actual
|
||||
// per-unit caster readiness. The cvar alone cannot promise a silhouette exists.
|
||||
handover: Res<ShadowHandover>,
|
||||
// Missing core/character plugins are a valid no-silhouette state: keep the oval there too.
|
||||
shadow_frame: Option<Res<ShadowFrame>>,
|
||||
ready: Option<Res<CharacterShadowReady>>,
|
||||
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
|
||||
cam_pos: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
// The unit's render alpha, asked of the unit — see the alpha block below.
|
||||
@@ -279,24 +286,9 @@ fn update_shadows(
|
||||
// report, answerable from a log instead of a debugger.
|
||||
mut census_at: Local<f32>,
|
||||
) {
|
||||
// `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
|
||||
// gate in the loop below fades the oval out for outdoor units so they don't wear both, while
|
||||
// INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so
|
||||
// an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER
|
||||
// lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must
|
||||
// leave it be.) With the cvar off — the shipped default — that gate is a single false branch
|
||||
// and this is the reference path.
|
||||
//
|
||||
// MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own
|
||||
// strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is
|
||||
// `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon.
|
||||
// So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no
|
||||
// realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws
|
||||
// the oval all night, which is what the complement below restores. The oval is NOT replaced by
|
||||
// a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`),
|
||||
// the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never
|
||||
// reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway.
|
||||
let sun_strength = sun_shadow_strength(lighting.celestial_dir().y);
|
||||
// MONKEY (moon shadows): a time jump or pending aim must restore the oval in the SAME
|
||||
// frame that the receiver goes fully lit. Do not independently reconstruct the clock weight.
|
||||
let lane_strength = handover.weight.abs();
|
||||
// No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
|
||||
// "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
|
||||
let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
|
||||
@@ -331,9 +323,12 @@ fn update_shadows(
|
||||
// nothing left to draw, so that stays the early-out the hard hide used to be.
|
||||
let indoors = room.is_some_and(|r| r.room().is_some());
|
||||
let mut lane_w = 1.0;
|
||||
if video.character_shadows && !indoors {
|
||||
if video.character_shadows && !indoors
|
||||
&& shadow_frame.as_ref().is_some_and(|frame| frame.active && !frame.suspended)
|
||||
&& ready.as_ref().is_some_and(|ready| ready.0.contains(&shadow.owner))
|
||||
{
|
||||
let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
|
||||
lane_w = blob_weight(sun_strength, cam_dist, video.shadow_distance);
|
||||
lane_w = blob_weight(lane_strength, cam_dist, video.shadow_distance);
|
||||
if lane_w <= 1.0 / 255.0 {
|
||||
hide(&mut key, &mut verts);
|
||||
continue;
|
||||
@@ -636,6 +631,7 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
|
||||
/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
|
||||
/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
|
||||
/// below — if the curve is ever tuned, tune both or the handover gains a seam.
|
||||
#[cfg(test)]
|
||||
fn sun_shadow_strength(sun_height: f32) -> f32 {
|
||||
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
@@ -714,6 +710,45 @@ mod tests {
|
||||
assert!((prev - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the oval yields to the NIGHT cast by exactly what that cast lands.
|
||||
///
|
||||
/// The bug this forbids is the one the feature creates if the blob is left alone: a unit at
|
||||
/// midnight now HAS a real silhouette shadow, and an unfaded oval under it is a second,
|
||||
/// differently-shaped shadow in the same place. The complement law already handles that — what
|
||||
/// has to hold is that the moon's weight enters it the same way the sun's does, and that
|
||||
/// `moonShadowStrength 0` puts the full oval back, bit for bit.
|
||||
///
|
||||
/// The sum is exact rather than approximate because the two weights are mutually exclusive
|
||||
/// (`benilla_world::lighting::moon_shadow_weight`, swept over the whole game day by its own
|
||||
/// test), so `sun + moon` IS the realtime lane's total strength at every instant.
|
||||
#[test]
|
||||
fn the_oval_yields_to_a_moon_cast_by_exactly_its_strength() {
|
||||
// Midnight, close in: the sun casts nothing, the moon casts at the shipped 0.35.
|
||||
let night_sun = sun_shadow_strength(-0.3);
|
||||
assert_eq!(night_sun, 0.0);
|
||||
let full = blob_weight(night_sun, 10.0, 70.0);
|
||||
assert_eq!(full, 1.0, "with the feature off the whole oval is still there");
|
||||
let under_moon = blob_weight(night_sun + 0.35, 10.0, 70.0);
|
||||
assert!(
|
||||
(under_moon - 0.65).abs() < 1e-6,
|
||||
"a 0.35 moon cast leaves 0.65 of the oval, not {under_moon}"
|
||||
);
|
||||
// …and the two together are the constant the day arm is: whatever the realtime lane lands,
|
||||
// the oval lands the rest.
|
||||
assert!((under_moon + 0.35 - 1.0).abs() < 1e-6);
|
||||
// Past the cascade edge the moon cast resolves nothing, so the full oval returns even
|
||||
// though the moon is up — the same edge behaviour the sun arm has.
|
||||
assert_eq!(blob_weight(night_sun + 0.35, 80.0, 70.0), 1.0);
|
||||
// The moonrise ramp reaches the oval monotonically: no pop as the weight lifts off zero.
|
||||
let mut prev = 1.0;
|
||||
for step in 0..=20 {
|
||||
let w = blob_weight(night_sun + 0.35 * step as f32 / 20.0, 10.0, 70.0);
|
||||
assert!(w <= prev + 1e-6, "the oval brightened as the moon rose, at step {step}");
|
||||
prev = w;
|
||||
}
|
||||
assert!((prev - 0.65).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
|
||||
#[test]
|
||||
fn box_clamp_caps_pre_scale() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! exists — remove either lane and the other is untouched.
|
||||
|
||||
use bevy::pbr::MeshMaterial3d;
|
||||
use bevy::ecs::entity::EntityHashSet;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
@@ -26,10 +27,17 @@ pub(crate) struct CharacterShadowPlugin;
|
||||
impl Plugin for CharacterShadowPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<CharacterLane>()
|
||||
.init_resource::<CharacterShadowReady>()
|
||||
.add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): roots/ancestors with triangles in the LAST completed caster build.
|
||||
/// Blobs consume this in PostUpdate, before this frame's Last rebuild: a newly uploaded proxy
|
||||
/// gets a render frame before its oval yields. Cadence skips retain the same geometry/verdict.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct CharacterShadowReady(pub EntityHashSet);
|
||||
|
||||
/// The character lane's retained per-frame caster.
|
||||
#[derive(Resource, Default)]
|
||||
struct CharacterLane {
|
||||
@@ -49,10 +57,13 @@ fn collect_character_shadows(
|
||||
mut demand: ResMut<ShadowDemand>,
|
||||
frame: Res<ShadowFrame>,
|
||||
mut lane: ResMut<CharacterLane>,
|
||||
mut ready: ResMut<CharacterShadowReady>,
|
||||
parents: Query<&ChildOf>,
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
parts: Query<
|
||||
(
|
||||
Entity,
|
||||
&PickMesh,
|
||||
&ModelPart,
|
||||
Option<&GlobalTransform>,
|
||||
@@ -71,6 +82,7 @@ fn collect_character_shadows(
|
||||
|
||||
// Rig down or lane off → drop our caster so it stops casting.
|
||||
if !(frame.active && on) {
|
||||
ready.0.clear();
|
||||
if let Some(entity) = lane.caster.take() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
@@ -81,6 +93,12 @@ fn collect_character_shadows(
|
||||
lane.rate.reset();
|
||||
return;
|
||||
}
|
||||
// MONKEY (moon shadows): keep the cache but suspend CPU skin/re-upload on feature-off nights.
|
||||
if frame.suspended {
|
||||
ready.0.clear();
|
||||
lane.rate.reset();
|
||||
return;
|
||||
}
|
||||
let Some(material) = frame.material.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -114,6 +132,7 @@ fn collect_character_shadows(
|
||||
if let Some(handle) = lane.mesh.clone() {
|
||||
if let Some(mesh) = meshes.get_mut(&handle) {
|
||||
let (mut positions, mut indices) = take_mesh_buffers(mesh);
|
||||
let mut built_parts = EntityHashSet::default();
|
||||
let (admitted, rejected) = collect_entity_geometry(
|
||||
&parts,
|
||||
&rigs,
|
||||
@@ -125,7 +144,14 @@ fn collect_character_shadows(
|
||||
frame.tall_reach,
|
||||
&mut positions,
|
||||
&mut indices,
|
||||
Some(&mut built_parts),
|
||||
);
|
||||
ready.0.clear();
|
||||
for part in built_parts {
|
||||
ready.0.insert(part);
|
||||
// The unit root owns its blob; mounts and equipment can be nested below it.
|
||||
ready.0.extend(parents.iter_ancestors(part));
|
||||
}
|
||||
let tris = (indices.len() / 3) as u32;
|
||||
restore_mesh_buffers(mesh, positions, indices);
|
||||
if shadow_trace() {
|
||||
|
||||
@@ -908,6 +908,24 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
|
||||
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
|
||||
same("gxVSync", "1").latched(),
|
||||
// MONKEY (advanced graphics): the one row on the Advanced Graphics page that is not a knob —
|
||||
// it is a NAME for a combination of the rows under it ([`LIGHTING_PRESETS`]). Writing a preset
|
||||
// name writes that preset's members; writing anything else is corrected on the next frame by
|
||||
// [`lighting_quality`], which re-DERIVES the name from the members those rows actually hold.
|
||||
// So the stored value is never stale: it is either a preset every member agrees with, or
|
||||
// `Custom`.
|
||||
//
|
||||
// A STRING row, not an int, so `config.toml` and `/console set lightingQuality Medium` both
|
||||
// read as what they mean — and so the ladder can gain a rung without renumbering a player's
|
||||
// saved value. `Row::numeric` is false for it (the default does not parse as a number), which
|
||||
// is what lets the registry accept a non-numeric write at all; `realmList` is the precedent.
|
||||
ours(
|
||||
"lightingQuality",
|
||||
"High",
|
||||
"benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \
|
||||
Medium / High, or Custom when the members match none of them; the reference has no \
|
||||
realtime light or shadow system to preset",
|
||||
),
|
||||
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
|
||||
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
|
||||
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
|
||||
@@ -968,6 +986,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
"1",
|
||||
"benilla's own: multiplier on the shadow caster-collection reach, 0.25..2 (1 = unchanged)",
|
||||
),
|
||||
// MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night — the
|
||||
// dial over the night lane's own shadow term (`benilla_world::lighting::MoonShadowStrength`,
|
||||
// bridged by `dynamic_interior` beside the spell gain). `0` is the FAITHFUL null: every
|
||||
// consumer guards on it, so a night at 0 renders as the build before the feature did — which
|
||||
// is also what the Off and Low presets ask for.
|
||||
ours(
|
||||
"moonShadowStrength",
|
||||
"0.35",
|
||||
"benilla's own: how dark a moon-shadowed fragment gets at night, 0..1 (0 = no moon \
|
||||
shadow, the reference's own night)",
|
||||
),
|
||||
// MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
|
||||
// instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
|
||||
// bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
|
||||
@@ -1087,8 +1116,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
// `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how
|
||||
// "20 % / 30 % darker" gets judged at all, and `1` on either is the restore.
|
||||
//
|
||||
// Neither touches the fires: a point light keeps its brightness under both dials, because the
|
||||
// ask is for a darker night AROUND the flame, not a dimmer flame.
|
||||
// Night gain leaves fire lights alone; interior gain also scales interior fixtures,
|
||||
// including their candlelight. Outdoor fires retain their brightness under both dials.
|
||||
ours(
|
||||
"nightGain",
|
||||
"0.45",
|
||||
@@ -1647,12 +1676,22 @@ impl Cvars {
|
||||
/// the minimap zoom, the camera views, the remembered character, the loading screen's tip
|
||||
/// cursor. Mirrored into the VM, persisted, and observed like any other write.
|
||||
pub(crate) fn set(&mut self, name: &str, value: &str) -> SetOutcome {
|
||||
self.write(name, value, false)
|
||||
let outcome = self.write(name, value, false);
|
||||
if name.eq_ignore_ascii_case("lightingQuality") {
|
||||
apply_lighting_preset(self, value);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// A write the VM's mirror already made — drained from its change queue.
|
||||
pub(crate) fn set_from_vm(&mut self, name: &str, value: &str) -> SetOutcome {
|
||||
self.write(name, value, true)
|
||||
let outcome = self.write(name, value, true);
|
||||
// Apply at the write's position in the queue: a later member edit must win,
|
||||
// including a re-selection of the currently displayed preset.
|
||||
if name.eq_ignore_ascii_case("lightingQuality") {
|
||||
apply_lighting_preset(self, value);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// **The table follows a value the engine already applied** — a mirror, not a write: the
|
||||
@@ -1917,6 +1956,165 @@ impl Plugin for CvarPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MONKEY (advanced graphics): the lighting preset ladder ──────────────────────────────────
|
||||
|
||||
/// **The one place the presets are written down** — the Advanced Graphics page's `lightingQuality`
|
||||
/// ladder, as (name, members) pairs in the order [`derive_lighting_quality`] tries them.
|
||||
///
|
||||
/// A preset is a NAME FOR A SET OF ROWS, not a knob: applying one writes each member through the
|
||||
/// ordinary registry path ([`Cvars::set`]), so every observer, clamp, bridge and save that a
|
||||
/// hand-typed `/console set` would run, runs. That is why this table lives beside the registry
|
||||
/// rather than in the options window's Lua: the page is one of four ways to reach it (the others
|
||||
/// being `/console`, the lighting debug panel, and a hand-edited `config.toml`), and a table
|
||||
/// carried by the window would be a table three of them cannot see.
|
||||
///
|
||||
/// **A preset names only the members it decides.** `Off` says nothing about `shadowMapSize` — with
|
||||
/// both shadow lanes off there is no map to size — and Low says nothing about the torch counts,
|
||||
/// which its `interiorShadows 0` has already made inert. Derivation matches on exactly the members
|
||||
/// a preset lists, so a row nobody's preset mentions never pushes the ladder to `Custom`.
|
||||
///
|
||||
/// Order matters twice: the ladder reads Off → High for a human, and the first preset whose whole
|
||||
/// member list matches wins. The four are mutually exclusive on `characterShadows` /
|
||||
/// `worldShadows` / `exteriorShadows`, so no value can answer to two of them — a property
|
||||
/// `every_preset_derives_back_to_its_own_name` holds rather than a reader has to check.
|
||||
pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
|
||||
// Everything this whole system invented, off — the client as it rendered before any of it
|
||||
// existed. Not "cheap": OFF, so a machine that cannot afford the lanes can say so in one
|
||||
// click, and so a bug report can be split from a taste report in one click too.
|
||||
(
|
||||
"Off",
|
||||
&[
|
||||
("characterShadows", "0"),
|
||||
("worldShadows", "0"),
|
||||
("interiorLight", "0"),
|
||||
("interiorShadows", "0"),
|
||||
("exteriorShadows", "0"),
|
||||
("spellLightGain", "0"),
|
||||
("fireLightGain", "0"),
|
||||
("nightGain", "1.0"),
|
||||
("interiorGain", "1.0"),
|
||||
("moonShadowStrength", "0"),
|
||||
("fireFlicker", "0"),
|
||||
],
|
||||
),
|
||||
// Character silhouettes and lit rooms, and nothing that costs a second shadow pass: no world
|
||||
// lane, no torch maps indoors or out, no moon term, and the smallest sun map on the ladder.
|
||||
(
|
||||
"Low",
|
||||
&[
|
||||
("characterShadows", "1"),
|
||||
("worldShadows", "0"),
|
||||
("shadowMapSize", "1024"),
|
||||
("interiorLight", "1"),
|
||||
("interiorShadows", "0"),
|
||||
("exteriorShadows", "0"),
|
||||
("spellLightGain", "1"),
|
||||
("fireLightGain", "1"),
|
||||
("nightGain", "0.45"),
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0"),
|
||||
("fireFlicker", "1"),
|
||||
],
|
||||
),
|
||||
// Both sun lanes, indoor torch shadows at half the residency, and the moon term — but no
|
||||
// OUTDOOR torch shadows, which are the one lane whose cost is paid in the open world where the
|
||||
// sun lanes are already paying.
|
||||
(
|
||||
"Medium",
|
||||
&[
|
||||
("characterShadows", "1"),
|
||||
("worldShadows", "1"),
|
||||
("shadowMapSize", "2048"),
|
||||
("interiorLight", "1"),
|
||||
("interiorShadows", "1"),
|
||||
("interiorShadowCasters", "6"),
|
||||
("interiorShadowDynamic", "2"),
|
||||
("exteriorShadows", "0"),
|
||||
("spellLightGain", "1"),
|
||||
("fireLightGain", "1"),
|
||||
("nightGain", "0.45"),
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0.35"),
|
||||
("fireFlicker", "1"),
|
||||
],
|
||||
),
|
||||
// **High IS the shipped default**, member for member — which is a property, not a coincidence:
|
||||
// `the_high_preset_is_the_registered_defaults` welds the two, so a fresh `benilla-config`
|
||||
// reads "High" on this row rather than "Custom".
|
||||
(
|
||||
"High",
|
||||
&[
|
||||
("characterShadows", "1"),
|
||||
("worldShadows", "1"),
|
||||
("shadowMapSize", "2048"),
|
||||
("interiorLight", "1"),
|
||||
("interiorShadows", "1"),
|
||||
("interiorShadowCasters", "12"),
|
||||
("interiorShadowDynamic", "4"),
|
||||
("exteriorShadows", "1"),
|
||||
("spellLightGain", "1"),
|
||||
("fireLightGain", "1"),
|
||||
("nightGain", "0.45"),
|
||||
("interiorGain", "0.5"),
|
||||
("moonShadowStrength", "0.35"),
|
||||
("fireFlicker", "1"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// What the ladder shows when the members match no preset. Not a preset: selecting it writes
|
||||
/// nothing, and the next derivation puts back whatever the members really say.
|
||||
pub(crate) const LIGHTING_CUSTOM: &str = "Custom";
|
||||
|
||||
/// Two registry values are the SAME setting when they are the same number — `"0"`, `"0.00"` and
|
||||
/// `"0.0"` all mean a lane that is off, and a preset whose member reads `"1"` must not be called
|
||||
/// `Custom` because a slider wrote `"1.00"`. Falls back to a case-insensitive string compare for
|
||||
/// the rows that hold words.
|
||||
fn same_value(a: &str, b: &str) -> bool {
|
||||
match (a.trim().parse::<f32>(), b.trim().parse::<f32>()) {
|
||||
(Ok(x), Ok(y)) => (x - y).abs() < 1e-4,
|
||||
_ => a.eq_ignore_ascii_case(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// **The name the members currently spell** — the first preset every one of whose members matches
|
||||
/// the live registry, or [`LIGHTING_CUSTOM`]. This is the only thing `lightingQuality` is ever
|
||||
/// allowed to say, which is what keeps the row from going stale behind a `/console` write.
|
||||
pub(crate) fn derive_lighting_quality(cvars: &Cvars) -> &'static str {
|
||||
for (name, members) in LIGHTING_PRESETS {
|
||||
let matched = members
|
||||
.iter()
|
||||
.all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v)));
|
||||
if matched {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
LIGHTING_CUSTOM
|
||||
}
|
||||
|
||||
/// Write one preset's members. Ordinary registry writes: observers, clamps and the save all run.
|
||||
/// Unknown names (including `Custom`) write nothing and say so by answering `false`.
|
||||
pub(crate) fn apply_lighting_preset(cvars: &mut Cvars, name: &str) -> bool {
|
||||
let Some((_, members)) = LIGHTING_PRESETS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
for (k, v) in *members {
|
||||
cvars.set(k, v);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Derive after all ordered writes, before the mirror/observer flush. File loading bypasses
|
||||
/// `set`, so saved members remain authoritative even when the saved preset name disagrees.
|
||||
fn lighting_quality(cvars: &mut Cvars) {
|
||||
let derived = derive_lighting_quality(cvars);
|
||||
// A derived label is a mirror, never another request to apply a preset.
|
||||
cvars.mirror("lightingQuality", derived);
|
||||
}
|
||||
|
||||
/// **What the environment took for this session, and what it set it to** — read off the knobs
|
||||
/// the env levers already seeded (`RenderScale::default()` reads `$WOW_RENDER_SCALE`, and so
|
||||
/// on), so the registry answers `GetCVar` with the value the client is actually running at.
|
||||
@@ -2119,6 +2317,7 @@ pub(crate) fn sync_cvars(
|
||||
}
|
||||
}
|
||||
let Some(mut script) = script else {
|
||||
lighting_quality(&mut cvars);
|
||||
// Nothing to mirror into; a later VM is seeded from the table, which carries it all.
|
||||
if cvars.has_outbox() {
|
||||
cvars.take_outbox();
|
||||
@@ -2146,6 +2345,7 @@ pub(crate) fn sync_cvars(
|
||||
cvars.set_from_vm(&name, &value);
|
||||
}
|
||||
}
|
||||
lighting_quality(&mut cvars);
|
||||
if seeded.claim(&script) {
|
||||
// The file's unclaimed entries go in FIRST (decision 1291): an addon's `RegisterCVar`
|
||||
// later starts its key at the saved value. Then the table itself, at its live values.
|
||||
@@ -2208,8 +2408,12 @@ pub(crate) fn sync_cvars(
|
||||
});
|
||||
}
|
||||
if cvars.has_outbox() {
|
||||
for (name, value) in cvars.take_outbox() {
|
||||
script.set_cvar_host(&name, &value);
|
||||
for (name, _) in cvars.take_outbox() {
|
||||
// A preset may have queued this before a newer VM member edit. Publish
|
||||
// the final registry value, never that older queued snapshot.
|
||||
if let Some(value) = cvars.get(&name) {
|
||||
script.set_cvar_host(&name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if cvars.has_events() {
|
||||
@@ -2249,6 +2453,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
|
||||
for (name, value) in changes {
|
||||
cvars.set_from_vm(&name, &value);
|
||||
}
|
||||
lighting_quality(&mut cvars);
|
||||
if cvars.has_outbox() {
|
||||
cvars.take_outbox(); // the VM this was for is going away
|
||||
}
|
||||
@@ -2660,7 +2865,7 @@ mod tests {
|
||||
// a row, forgot its weld" fail HERE: the length check below is the gate.
|
||||
let shadows = VideoConfig::default();
|
||||
let flag = |b: bool| if b { 1.0 } else { 0.0 };
|
||||
let lighting: [(&str, f32); 29] = [
|
||||
let lighting: [(&str, f32); 30] = [
|
||||
// The two sun lanes and the cascade they share.
|
||||
("worldShadows", flag(shadows.world_shadows)),
|
||||
("characterShadows", flag(shadows.character_shadows)),
|
||||
@@ -2671,6 +2876,8 @@ mod tests {
|
||||
("characterShadowRate", shadows.character_shadow_rate as f32),
|
||||
("worldShadowRate", shadows.world_shadow_rate as f32),
|
||||
("shadowCasterReach", shadows.shadow_caster_reach),
|
||||
// MONKEY (moon shadows): the night lane's own shadow darkness.
|
||||
("moonShadowStrength", shadows.moon_shadow_strength),
|
||||
// MONKEY (dynamic interiors): the room lane's switch and its light law.
|
||||
("interiorLight", flag(shadows.interior_light)),
|
||||
("interiorAmbient", shadows.interior_ambient),
|
||||
@@ -2710,9 +2917,9 @@ mod tests {
|
||||
assert_eq!(d[name], want, "{name}: registered default left the knob");
|
||||
}
|
||||
// …and the census IS the row set. A name here that nothing registers would weld against a
|
||||
// row the client does not have; the length is the other half — 29 rows, 29 welds.
|
||||
// row the client does not have; the length is the other half — 30 rows, 30 welds.
|
||||
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
|
||||
assert_eq!(welded.len(), 29, "the lighting lane welds 29 distinct rows");
|
||||
assert_eq!(welded.len(), 30, "the lighting lane welds 30 distinct rows");
|
||||
for name in &welded {
|
||||
assert!(
|
||||
REGISTERED.iter().any(|r| r.name == *name),
|
||||
@@ -2727,6 +2934,10 @@ mod tests {
|
||||
// candle-lit surfaces moving under +10 % — only true at this number.
|
||||
assert_eq!(d["interiorBakeFloor"], 0.12);
|
||||
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
|
||||
// The 31st lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
|
||||
// only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
|
||||
// rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
|
||||
// defaults` — a fresh config must read "High", not "Custom".
|
||||
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
|
||||
// The pane half-rate (1444) welds to the portrait knob's shipped default.
|
||||
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
|
||||
@@ -2736,6 +2947,205 @@ mod tests {
|
||||
assert_eq!(d["renderScale"], 1.0);
|
||||
}
|
||||
|
||||
// ── MONKEY (advanced graphics): the preset ladder ─────────────────────────────────────────
|
||||
|
||||
/// A registry holding nothing but the registered defaults — the state a fresh
|
||||
/// `benilla-config` boots into, which is what every claim below is measured against.
|
||||
fn fresh_registry() -> Cvars {
|
||||
Cvars::default()
|
||||
}
|
||||
|
||||
/// **The round trip, for every rung**: apply a preset, derive, and get that preset's own name
|
||||
/// back. This is the property the whole ladder rests on — without it the dropdown would show
|
||||
/// `Custom` the instant after the player picked something, which is the exact failure the
|
||||
/// "derived, never stored stale" rule exists to prevent.
|
||||
#[test]
|
||||
fn every_preset_derives_back_to_its_own_name() {
|
||||
for (from, _) in LIGHTING_PRESETS {
|
||||
for (name, _) in LIGHTING_PRESETS {
|
||||
let mut cvars = fresh_registry();
|
||||
apply_lighting_preset(&mut cvars, from);
|
||||
cvars.set("shadowDistance", "120");
|
||||
cvars.set("interiorShadowSoft", "2.5");
|
||||
assert!(apply_lighting_preset(&mut cvars, name), "{name}: applied");
|
||||
assert_eq!(derive_lighting_quality(&cvars), *name, "{from} -> {name}");
|
||||
assert_eq!(cvars.get("shadowDistance"), Some("120"));
|
||||
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
|
||||
for member in ["fireLightGain", "nightGain", "interiorGain"] {
|
||||
let expected = if *name == "Off" {
|
||||
if member == "fireLightGain" { "0" } else { "1.0" }
|
||||
} else {
|
||||
cvars.default_of(member).unwrap()
|
||||
};
|
||||
assert!(same_value(cvars.get(member).unwrap(), expected), "{name}/{member}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **High is the shipped default, member for member.** A fresh config must read "High" on this
|
||||
/// row rather than "Custom" — otherwise the very first thing a player sees on the page is a
|
||||
/// word that says their settings are a hand-made combination when they have touched nothing.
|
||||
#[test]
|
||||
fn the_high_preset_is_the_registered_defaults() {
|
||||
let cvars = fresh_registry();
|
||||
assert_eq!(derive_lighting_quality(&cvars), "High");
|
||||
assert_eq!(
|
||||
cvars.get("lightingQuality"),
|
||||
Some("High"),
|
||||
"the registered default agrees with what a fresh registry derives"
|
||||
);
|
||||
// …and the table is not merely CONSISTENT with the defaults, it IS them: a member whose
|
||||
// registered default moved without its preset entry moving would still derive "High"
|
||||
// above (both sides move together), so the weld is checked against the row's own default.
|
||||
let (_, members) = LIGHTING_PRESETS
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "High")
|
||||
.expect("the High rung");
|
||||
for (k, v) in *members {
|
||||
assert!(
|
||||
same_value(cvars.default_of(k).expect("a registered member"), v),
|
||||
"{k}: the High preset says {v}, the registry's default says {:?}",
|
||||
cvars.default_of(k)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **One member off the rung is `Custom`** — for every rung, and for every member of it, which
|
||||
/// is the half that keeps the derivation from being a lucky match on a favourite row.
|
||||
#[test]
|
||||
fn one_changed_member_derives_custom() {
|
||||
for (name, members) in LIGHTING_PRESETS {
|
||||
for (k, v) in *members {
|
||||
let mut cvars = fresh_registry();
|
||||
apply_lighting_preset(&mut cvars, name);
|
||||
// Somewhere else on the row's own scale: the flags flip, the numbers move by one.
|
||||
let moved = match v.trim().parse::<f32>() {
|
||||
Ok(x) if x == 0.0 => "1".to_string(),
|
||||
Ok(x) if x == 1.0 => "0".to_string(),
|
||||
Ok(x) => (x + 1.0).to_string(),
|
||||
Err(_) => format!("{v}x"),
|
||||
};
|
||||
assert_eq!(cvars.set(k, &moved), SetOutcome::Changed, "{name}/{k}");
|
||||
assert_eq!(
|
||||
derive_lighting_quality(&cvars),
|
||||
LIGHTING_CUSTOM,
|
||||
"{name}: {k} moved to {moved} and the ladder still says {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that says the same NUMBER is the same setting: the options window's sliders write
|
||||
/// `"1"`, a hand-edited config may say `"1.0"`, and `%.2f` formatting elsewhere says `"1.00"`.
|
||||
/// A ladder that compared strings would call every one of those `Custom`.
|
||||
#[test]
|
||||
fn the_ladder_compares_numbers_not_spellings() {
|
||||
let mut cvars = fresh_registry();
|
||||
apply_lighting_preset(&mut cvars, "High");
|
||||
for spelling in ["1.0", "1.00", "1"] {
|
||||
cvars.set("spellLightGain", spelling);
|
||||
assert_eq!(derive_lighting_quality(&cvars), "High", "{spelling}");
|
||||
}
|
||||
cvars.set("moonShadowStrength", "0.350");
|
||||
assert_eq!(derive_lighting_quality(&cvars), "High");
|
||||
}
|
||||
|
||||
/// `Custom` is not a rung: picking it writes nothing, so the members — and therefore the name
|
||||
/// the next derivation lands on — are exactly where they were.
|
||||
#[test]
|
||||
fn picking_custom_writes_nothing() {
|
||||
let mut cvars = fresh_registry();
|
||||
apply_lighting_preset(&mut cvars, "Medium");
|
||||
assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM));
|
||||
assert!(!apply_lighting_preset(&mut cvars, "Ultra"));
|
||||
assert_eq!(derive_lighting_quality(&cvars), "Medium");
|
||||
}
|
||||
|
||||
/// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make
|
||||
/// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the
|
||||
/// only symptom would be a dropdown stuck on `Custom`.
|
||||
#[test]
|
||||
fn every_preset_member_is_registered_and_inside_its_observers_clamp() {
|
||||
let mut app = cvar_app();
|
||||
for (name, members) in LIGHTING_PRESETS {
|
||||
for (k, value) in *members {
|
||||
assert!(
|
||||
REGISTERED.iter().any(|r| r.name == *k),
|
||||
"{name}: member {k} is not registered"
|
||||
);
|
||||
// Drive the real setter: a clamp, integer conversion or map-size snap
|
||||
// must leave every preset value unchanged.
|
||||
apply(&mut app, k, "-1");
|
||||
apply(&mut app, k, value);
|
||||
let video = res::<VideoConfig>(&app);
|
||||
let applied = match *k {
|
||||
"characterShadows" => video.character_shadows as u32 as f32,
|
||||
"worldShadows" => video.world_shadows as u32 as f32,
|
||||
"interiorLight" => video.interior_light as u32 as f32,
|
||||
"interiorShadows" => video.interior_shadows as u32 as f32,
|
||||
"exteriorShadows" => video.exterior_shadows as u32 as f32,
|
||||
"shadowMapSize" => video.shadow_map_size as f32,
|
||||
"interiorShadowCasters" => video.interior_shadow_casters as f32,
|
||||
"interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
|
||||
"spellLightGain" => video.spell_light_gain,
|
||||
"fireLightGain" => video.fire_light_gain,
|
||||
"moonShadowStrength" => video.moon_shadow_strength,
|
||||
"fireFlicker" => video.fire_flicker,
|
||||
"nightGain" => video.night_gain,
|
||||
"interiorGain" => video.interior_gain,
|
||||
_ => panic!("add the observer readback for {k}"),
|
||||
};
|
||||
assert_eq!(applied, value.parse::<f32>().unwrap(), "{name}/{k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_then_member_edit_survives_sync_observers_and_later_flushes() {
|
||||
for seeded in [false, true] {
|
||||
for flush_preset_first in [false, true] {
|
||||
let mut app = cvar_app();
|
||||
app.world_mut().non_send_resource_mut::<UiScript>()
|
||||
.register_cvars(registered_pairs());
|
||||
// Run the production Update schedule without Startup's on-disk config load.
|
||||
if seeded {
|
||||
app.world_mut().run_schedule(Update);
|
||||
}
|
||||
app.world_mut().non_send_resource_mut::<UiScript>()
|
||||
.run("SetCVar('lightingQuality', 'Low')").unwrap();
|
||||
if flush_preset_first {
|
||||
app.world_mut().run_schedule(Update);
|
||||
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 1024);
|
||||
}
|
||||
app.world_mut().non_send_resource_mut::<UiScript>()
|
||||
.run("SetCVar('shadowMapSize', '4096')").unwrap();
|
||||
for _ in 0..3 {
|
||||
app.world_mut().run_schedule(Update);
|
||||
let cvars = res::<Cvars>(&app);
|
||||
assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
|
||||
assert_eq!(cvars.get("lightingQuality"), Some(LIGHTING_CUSTOM));
|
||||
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 4096);
|
||||
let script = app.world().non_send_resource::<UiScript>();
|
||||
assert_eq!(script.cvar("shadowMapSize").as_deref(), Some("4096"));
|
||||
assert_eq!(script.cvar("lightingQuality").as_deref(), Some(LIGHTING_CUSTOM));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_and_spell_gains_above_two_reach_the_video_observer() {
|
||||
let mut app = cvar_app();
|
||||
for value in ["3.25", "4"] {
|
||||
apply(&mut app, "fireLightGain", value);
|
||||
apply(&mut app, "spellLightGain", value);
|
||||
let expected = value.parse::<f32>().unwrap();
|
||||
assert_eq!(res::<VideoConfig>(&app).fire_light_gain, expected);
|
||||
assert_eq!(res::<VideoConfig>(&app).spell_light_gain, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every arm, through the registry and its observers.** The old central `apply_to_knobs`
|
||||
/// match is gone (2303); each arm lives beside the knob it writes, and this drives the whole
|
||||
/// set through a real `App` — a host write, the observers firing synchronously — so a knob
|
||||
|
||||
@@ -43,6 +43,7 @@ macro_rules! knob {
|
||||
|
||||
use Widget::{Choice, Integer, Slider};
|
||||
|
||||
// This developer-only panel intentionally keeps its labels and help text in English.
|
||||
const INTERIOR: &[Knob] = &[
|
||||
knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."),
|
||||
knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."),
|
||||
@@ -89,6 +90,10 @@ const GROUPS: &[(&str, &[Knob])] = &[
|
||||
]),
|
||||
("Night", &[
|
||||
knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."),
|
||||
// MONKEY (moon shadows): in "Night" rather than beside the sun dials, because that is
|
||||
// where both its cost and its taste question live — it is inert by day, and what it buys
|
||||
// is "the world is not flat after dark" for a second shadowed directional term.
|
||||
knob!("moonShadowStrength", moon_shadow_strength, Slider(0.0..=1.0), "How dark a moon-shadowed fragment gets at night; 0 = no moon shadow."),
|
||||
]),
|
||||
("Debug", &[
|
||||
knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."),
|
||||
@@ -147,8 +152,29 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
|
||||
ui.weak("Lighting controls are available once the UI session is ready.");
|
||||
return;
|
||||
};
|
||||
// MONKEY (advanced graphics): the `lightingQuality` ladder — the same row the player reaches
|
||||
// on the Advanced Graphics page, so this panel and that window cannot show different answers.
|
||||
// The rungs are `cvars::LIGHTING_PRESETS`, never a second table here; the CURRENT name is read
|
||||
// off the VM mirror rather than derived locally, because the registry's own per-frame
|
||||
// derivation is the one authority on it and a second derivation would be a second opinion.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Presets");
|
||||
ui.label("Lighting quality")
|
||||
.on_hover_text("Writes the member rows below; reads back Custom when they match no rung.");
|
||||
let current = script.cvar("lightingQuality").unwrap_or_default();
|
||||
for (name, _) in crate::cvars::LIGHTING_PRESETS {
|
||||
if ui
|
||||
.selectable_label(current.eq_ignore_ascii_case(name), *name)
|
||||
.clicked()
|
||||
{
|
||||
script.set_cvar_engine("lightingQuality", name);
|
||||
}
|
||||
}
|
||||
if current.eq_ignore_ascii_case(crate::cvars::LIGHTING_CUSTOM) {
|
||||
ui.weak(crate::cvars::LIGHTING_CUSTOM);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Interior presets");
|
||||
for (label, values) in [
|
||||
// MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the
|
||||
// presets because it is a room BRIGHTNESS INPUT exactly like the three beside it.
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
//!
|
||||
//! MONKEY (spellLightGain): and `spellLightGain` → [`benilla_world::lighting::SpellLightGain`],
|
||||
//! the third of the same shape.
|
||||
use benilla_world::lighting::{DynamicInteriors, FireLightGain, SpellLightGain};
|
||||
//!
|
||||
//! MONKEY (moon shadows): and `moonShadowStrength` →
|
||||
//! [`benilla_world::lighting::MoonShadowStrength`], the fourth. Same shape, same guard, same
|
||||
//! liveness requirement — and `0` is that lane's faithful null the way `fireLightGain 0` is the
|
||||
//! synthesised-fire lane's kill switch. It is what the Advanced Graphics page's Moon Shadows row
|
||||
//! and the Off/Low presets reach.
|
||||
use benilla_world::lighting::{DynamicInteriors, FireLightGain, MoonShadowStrength, SpellLightGain};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::video::VideoConfig;
|
||||
@@ -46,6 +52,9 @@ fn bridge(
|
||||
// an interior knob at all: a fireball down a street takes it exactly as one down a corridor
|
||||
// does, and `FireLightGain` beside it already stands as the precedent for that.
|
||||
mut spell: ResMut<SpellLightGain>,
|
||||
// MONKEY (moon shadows): the night lane's shadow darkness, on the same bridge and for the same
|
||||
// reasons as the two gains above it — one live `f32`, one benilla-world resource, one guard.
|
||||
mut moon: ResMut<MoonShadowStrength>,
|
||||
) {
|
||||
if fire.0 != video.fire_light_gain {
|
||||
fire.0 = video.fire_light_gain;
|
||||
@@ -53,6 +62,9 @@ fn bridge(
|
||||
if spell.0 != video.spell_light_gain {
|
||||
spell.0 = video.spell_light_gain;
|
||||
}
|
||||
if moon.0 != video.moon_shadow_strength {
|
||||
moon.0 = video.moon_shadow_strength;
|
||||
}
|
||||
let want = DynamicInteriors {
|
||||
enabled: video.interior_light,
|
||||
ambient: video.interior_ambient,
|
||||
|
||||
@@ -51,6 +51,9 @@ use carried_light::spawn_carried_lights;
|
||||
// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit,
|
||||
// missile and dest-anchored lanes.
|
||||
use carried_light::spawn_spell_light;
|
||||
// MONKEY (area spell light): and the same file's PERSISTENT GROUND spawner — one light per
|
||||
// DynamicObject area effect, hung on the anchor so the server's destroy is what ends it.
|
||||
use carried_light::spawn_area_spell_light;
|
||||
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
|
||||
// carried light a cube-shadow slot.
|
||||
pub(crate) use carried_light::CarriedLightMotion;
|
||||
|
||||
@@ -38,7 +38,11 @@ use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
|
||||
// MONKEY (spell light): the effect-side lifecycle this file's spawn helper stamps on — the
|
||||
// envelope, the budget and the kill switch all live beside the rest of the effect lifecycle
|
||||
// (`spell_fx::lifecycle`), because that is what a spell light's lifetime IS.
|
||||
use super::spell_fx::{spell_lights_enabled, SpellLight, SpellLightMode, SPELL_BURST_SPAN};
|
||||
// MONKEY (area spell light): `AreaSpellLight` too — the persistent ground lane's marker, stamped
|
||||
// here beside the rest of a spell light's tags.
|
||||
use super::spell_fx::{
|
||||
spell_lights_enabled, AreaSpellLight, SpellLight, SpellLightMode, SPELL_BURST_SPAN,
|
||||
};
|
||||
|
||||
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
|
||||
/// an entity's model.
|
||||
@@ -127,8 +131,12 @@ pub(super) fn spawn_carried_lights(
|
||||
commands,
|
||||
parent,
|
||||
local,
|
||||
l,
|
||||
fx,
|
||||
SpellLightSource {
|
||||
color: l.def.diffuse_color,
|
||||
intensity: l.def.diffuse_intensity,
|
||||
kind: fx.kind,
|
||||
onset: fx.onset,
|
||||
},
|
||||
// A GameObject that carries a spell light IS a one-shot: a firework shell, a
|
||||
// trigger's effect model. Nothing on this lane holds.
|
||||
SpellLightMode::Burst {
|
||||
@@ -210,7 +218,125 @@ pub(super) fn spawn_spell_light(
|
||||
let Some((l, fx)) = lights.iter().find_map(|l| l.spell.map(|fx| (l, fx))) else {
|
||||
return false;
|
||||
};
|
||||
spawn_spell_light_child(commands, root, l.def.position, l, fx, mode).is_some()
|
||||
spawn_spell_light_child(
|
||||
commands,
|
||||
root,
|
||||
l.def.position,
|
||||
SpellLightSource {
|
||||
color: l.def.diffuse_color,
|
||||
intensity: l.def.diffuse_intensity,
|
||||
kind: fx.kind,
|
||||
onset: fx.onset,
|
||||
},
|
||||
mode,
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light): the ONE light a persistent ground effect throws, hung on the
|
||||
/// DynamicObject anchor itself so it lives and dies with the area object.
|
||||
///
|
||||
/// `kind` is the area lane's own verdict ([`benilla_formats::area_light_kind`] — the model path,
|
||||
/// then the spell's school, then the model's hue), and it is handed in rather than read back off
|
||||
/// the model because **the model routinely has nothing to read**: Flamestrike's burning patch and
|
||||
/// Explosive Trap's are flat animated decals with zero particle emitters, so
|
||||
/// `synthesize_spell_light` returns `None` for exactly the effects this feature is about. Where the
|
||||
/// model DOES carry a synthesised light we take its colour, intensity and onset (the artist's own
|
||||
/// ramp beats any default); where it does not we light it from the school's hue at the area
|
||||
/// fallback rung.
|
||||
///
|
||||
/// The light hangs [`AREA_LIGHT_LIFT`](benilla_formats::AREA_LIGHT_LIFT) above the emitter's own
|
||||
/// point — a dynobj sits ON the ground, and the faithful `1/(0.7d + 0.03d²)` falloff at `d → 0`
|
||||
/// would blow out the terrain under the centre while the rim of the same patch got nothing.
|
||||
///
|
||||
/// `radius` is the wire `DYNAMICOBJECT_RADIUS`: it sizes the interior pool
|
||||
/// ([`benilla_formats::area_reach`], not the shared intensity bucket — an area effect's footprint
|
||||
/// is stated on the wire and guessing it from particle size would be a worse answer than the one we
|
||||
/// were handed) and it is the impact dedupe's test radius.
|
||||
pub(super) fn spawn_area_spell_light(
|
||||
commands: &mut Commands,
|
||||
lights: &[benilla_assets::ModelLight],
|
||||
kind: benilla_formats::SpellLightKind,
|
||||
anchor: Entity,
|
||||
radius: f32,
|
||||
) -> bool {
|
||||
if !kind.lights() {
|
||||
return false;
|
||||
}
|
||||
// The model's own synthesised light, if it has one — its `SpellLightInfo` is deliberately NOT
|
||||
// read: the school is the area lane's (handed in above, school column included) and the onset
|
||||
// is a fact about an emitter's FIRST pass, i.e. a fuse, on an object that loops until the
|
||||
// server destroys it. The ground catches when the object appears.
|
||||
let synth = lights.iter().find(|l| l.spell.is_some());
|
||||
let (local, source) = match synth {
|
||||
Some(l) => (
|
||||
l.def.position,
|
||||
SpellLightSource {
|
||||
color: benilla_formats::area_color(kind, Some(l.def.diffuse_color)),
|
||||
intensity: l.def.diffuse_intensity,
|
||||
kind,
|
||||
onset: 0.0,
|
||||
},
|
||||
),
|
||||
// The decal-only half: no emitter to read, so the school's own hue at the area rung, hung
|
||||
// over the object's origin (its model has no emitter position either).
|
||||
None => (
|
||||
[0.0, 0.0, 0.0],
|
||||
SpellLightSource {
|
||||
color: benilla_formats::area_color(kind, None),
|
||||
intensity: benilla_formats::AREA_FALLBACK_INTENSITY,
|
||||
kind,
|
||||
onset: 0.0,
|
||||
},
|
||||
),
|
||||
};
|
||||
let lifted = [
|
||||
local[0],
|
||||
local[1],
|
||||
local[2] + benilla_formats::AREA_LIGHT_LIFT,
|
||||
];
|
||||
// The breathing phase: seeded from the anchor's bits so two overlapping patches swell out of
|
||||
// step, and stable for the light's whole life (it is baked into the mode, never re-rolled).
|
||||
let phase = (anchor.to_bits() % 1000) as f32 * 0.006_283_2;
|
||||
let Some(glow) = spawn_spell_light_child(
|
||||
commands,
|
||||
anchor,
|
||||
lifted,
|
||||
source,
|
||||
SpellLightMode::Area { phase },
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let reach = benilla_formats::area_reach(radius);
|
||||
commands.entity(glow).insert((
|
||||
AreaSpellLight { radius },
|
||||
// The pool's own size, stated rather than bucketed — see [`benilla_formats::area_reach`].
|
||||
benilla_world::lighting::LightReach(reach),
|
||||
));
|
||||
if benilla_assets::trace::enabled() {
|
||||
// Beside the shared `spell light` line the helper already wrote: the two numbers only an
|
||||
// area light has, so "did the patch light, and how big was its pool" is answered from the
|
||||
// trace instead of by eye.
|
||||
benilla_assets::trace::line(
|
||||
"fx",
|
||||
&format!("area light e={glow} radius={radius:.1} reach={reach:.1}"),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What one spell light is made of, as its spawn site knows it — the synthesised model light's
|
||||
/// numbers, or (MONKEY (area spell light)) the area lane's school-derived stand-in for a model that
|
||||
/// carries none.
|
||||
struct SpellLightSource {
|
||||
/// Linear RGB, hue preserved.
|
||||
color: [f32; 3],
|
||||
/// The authored `diffuse_intensity` the [`point_light`] recipe scales.
|
||||
intensity: f32,
|
||||
/// The school, for the trace and any future per-school gain.
|
||||
kind: benilla_formats::SpellLightKind,
|
||||
/// Seconds from the instance's birth before the light comes up (a firework's fuse).
|
||||
onset: f32,
|
||||
}
|
||||
|
||||
/// The shared body of the two spawn sites (the effect lanes' [`spawn_spell_light`] and the
|
||||
@@ -244,14 +370,13 @@ fn spawn_spell_light_child(
|
||||
commands: &mut Commands,
|
||||
parent: Entity,
|
||||
local: [f32; 3],
|
||||
l: &benilla_assets::ModelLight,
|
||||
fx: benilla_assets::SpellLightInfo,
|
||||
fx: SpellLightSource,
|
||||
mode: SpellLightMode,
|
||||
) -> Option<Entity> {
|
||||
if !spell_lights_enabled() {
|
||||
return None;
|
||||
}
|
||||
let lit = point_light(l.def.diffuse_color, l.def.diffuse_intensity);
|
||||
let lit = point_light(fx.color, fx.intensity);
|
||||
// Born DARK and raised by the envelope: the onset is real time (a firework's fuse), and a
|
||||
// light that showed its full strength on its first frame and only then ramped would flash
|
||||
// once before every effect it belongs to.
|
||||
|
||||
@@ -101,6 +101,31 @@ impl GroundFx {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light): what an armed DynamicObject anchor knows about the light its area
|
||||
/// visual should throw, stamped at arm time and read once the model finishes building.
|
||||
///
|
||||
/// It exists because the two halves of the answer arrive at different moments, in different
|
||||
/// systems. `radius` (the wire AoE) and `school` (the spell's `Spell.dbc` column) are only in hand
|
||||
/// at [`arm_ground_effects`], where the create's fields and the spell catalog are; the model's own
|
||||
/// verdict — whether its emitters synthesised a light, and in what hue — is only in hand at
|
||||
/// [`attach_ground_fx_models`], however many frames later the M2 finishes building. Carrying the
|
||||
/// first pair forward on the anchor is cheaper and far less fragile than re-resolving the spell id
|
||||
/// there.
|
||||
///
|
||||
/// Its PRESENCE is load-bearing too: it marks the instance as a dynobj AREA visual, so the looping
|
||||
/// branch of the attach takes the area verdict *including its refusals*. Without that a frost
|
||||
/// area's visual A would fall through to the impact-flash light this lane used to spawn for it, and
|
||||
/// Blizzard would light after all.
|
||||
#[derive(Component)]
|
||||
pub(super) struct AreaLightPlan {
|
||||
/// `DYNAMICOBJECT_RADIUS` — the effect's real footprint (the shard emitter spreads over exactly
|
||||
/// it), which sizes the pool and is the impact dedupe's test radius.
|
||||
radius: f32,
|
||||
/// The spell's `Spell.dbc` **School** index — the locale-proof half of the classification
|
||||
/// ([`benilla_formats::area_light_kind`]).
|
||||
school: u32,
|
||||
}
|
||||
|
||||
/// The shard emitter riding a DynamicObject anchor (visual B — the ref's `AUBlizzardObject`).
|
||||
/// Dies with the anchor (emission stops at destroy); its spawned shards are free entities and
|
||||
/// finish on their own — the ref's emitter tail.
|
||||
@@ -164,6 +189,14 @@ pub(super) fn arm_ground_effects(
|
||||
.dynamicobject_position()
|
||||
.map(|(_, f)| f)
|
||||
.unwrap_or(0.0);
|
||||
// MONKEY (area spell light): stamp the two facts only this system holds, for the attach to
|
||||
// finish the verdict with. Unconditional — a plan whose school ends up dark is exactly how
|
||||
// Blizzard's visual A is told to throw NO light (see [`AreaLightPlan`]).
|
||||
let radius = store.0.dynamicobject_radius().unwrap_or(0.0);
|
||||
commands.entity(anchor).insert(AreaLightPlan {
|
||||
radius,
|
||||
school: spells.catalog.get(spell_id).map_or(0, |d| d.school),
|
||||
});
|
||||
// Visual A — the object's own model, gated on field 11, Z-rotated by FACING. A child of
|
||||
// the anchor: the destroy pop takes it exactly like the ref's scene-node teardown.
|
||||
if stages.area_gate != 0 && stages.area_effect != 0 {
|
||||
@@ -189,7 +222,7 @@ pub(super) fn arm_ground_effects(
|
||||
ensure_model(&mut fx, &asset_server, &path);
|
||||
commands.entity(anchor).insert(ShardEmitter {
|
||||
path,
|
||||
radius: store.0.dynamicobject_radius().unwrap_or(0.0),
|
||||
radius,
|
||||
rate: proc.params[1],
|
||||
accum: 0.0,
|
||||
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
|
||||
@@ -264,13 +297,49 @@ pub(super) fn tick_shard_emitters(
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light): how far ABOVE OR BELOW a live area pool's own height an impact may
|
||||
/// still be deduped against it (yd).
|
||||
///
|
||||
/// The footprint test itself is HORIZONTAL, because that is what `DYNAMICOBJECT_RADIUS` describes —
|
||||
/// a disc of ground. A vertical window is needed anyway so the disc does not become an infinite
|
||||
/// column: a Rain of Fire on the bridge above must not silence the impacts under it. Generous
|
||||
/// enough to cover the lift the pool hangs at plus a shard landing on a slope, tight enough to be a
|
||||
/// storey.
|
||||
const AREA_DEDUPE_HEIGHT: f32 = 10.0;
|
||||
|
||||
/// Is `at` (Bevy world space) inside a live area pool's footprint? See [`AREA_DEDUPE_HEIGHT`].
|
||||
fn inside_a_live_area(
|
||||
at: Vec3,
|
||||
areas: &Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
|
||||
) -> bool {
|
||||
areas.iter().any(|(gt, area)| {
|
||||
let d = at - gt.translation();
|
||||
d.y.abs() <= AREA_DEDUPE_HEIGHT && d.x * d.x + d.z * d.z <= area.radius * area.radius
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach model parts to pending instances whose M2 finished building (the missile pattern —
|
||||
/// free world models, ground-anchored so authored flat quads decal to the terrain), start the
|
||||
/// one-shot clocks, and run both reapers (one-shot expiry; the loop-repeat override).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn attach_ground_fx_models(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
|
||||
// MONKEY (area spell light): `ChildOf` joins a VISUAL A instance to the DynamicObject anchor
|
||||
// that owns it — where the plan is, and the entity the light must hang on so it dies with the
|
||||
// area object. `Transform` is the instance's world point for a FREE one (a shard, a dest
|
||||
// one-shot), which is the dedupe's input.
|
||||
mut instances: Query<(
|
||||
Entity,
|
||||
&mut GroundFx,
|
||||
Option<&mut AnimationPlayer>,
|
||||
Option<&ChildOf>,
|
||||
&Transform,
|
||||
)>,
|
||||
// MONKEY (area spell light): the arm-time half of the area verdict, read through the anchor.
|
||||
plans: Query<&AreaLightPlan>,
|
||||
// MONKEY (area spell light): every live area pool, for the impact dedupe.
|
||||
areas: Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
|
||||
fx: Option<ResMut<SpellFx>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut wow_materials: ResMut<Assets<benilla_assets::materials::WowModelMaterial>>,
|
||||
@@ -282,7 +351,7 @@ pub(super) fn attach_ground_fx_models(
|
||||
) {
|
||||
let Some(mut fx) = fx else { return };
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, mut inst, player) in &mut instances {
|
||||
for (entity, mut inst, player, parent, at) in &mut instances {
|
||||
if !inst.spawned {
|
||||
ensure_model(&mut fx, &asset_server, &inst.path);
|
||||
let Some(dm) = fx.models.get(&inst.path) else {
|
||||
@@ -313,22 +382,59 @@ pub(super) fn attach_ground_fx_models(
|
||||
continue; // model still building — attach on a later pass
|
||||
}
|
||||
inst.spawned = true;
|
||||
// MONKEY (area spell light): a VISUAL A on an armed anchor is a persistent ground
|
||||
// effect, and takes the AREA lane instead of the flash below — one light for the
|
||||
// DynamicObject's whole life, hung on the ANCHOR (not on this instance) so the
|
||||
// server's destroy is what ends it. `area_light_kind` finishes the verdict the plan
|
||||
// started, and its refusals are honoured: when it says dark, this instance gets no
|
||||
// light at all rather than falling through to the flash.
|
||||
let plan = parent.map(|c| c.parent()).and_then(|anchor| {
|
||||
plans
|
||||
.get(anchor)
|
||||
.ok()
|
||||
.map(|plan| (anchor, plan))
|
||||
.filter(|_| inst.looping)
|
||||
});
|
||||
if let Some((anchor, plan)) = plan {
|
||||
let kind = benilla_formats::area_light_kind(
|
||||
&inst.path,
|
||||
plan.school,
|
||||
dm.lights.iter().find_map(|l| l.spell.map(|fx| fx.kind)),
|
||||
);
|
||||
super::spawn_area_spell_light(
|
||||
&mut commands,
|
||||
&dm.lights,
|
||||
kind,
|
||||
anchor,
|
||||
plan.radius,
|
||||
);
|
||||
continue; // no one-shot clock and no flash — the area object owns both ends
|
||||
}
|
||||
// MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose
|
||||
// light must not hold — a Fire Nova lights the ground it lands on and is gone. The
|
||||
// burst span is the model's own first-sequence duration where it authors one (so the
|
||||
// light fades with the effect rather than on a guess), the shared default otherwise;
|
||||
// a LOOPING plant (a persistent ground aura) takes the default too, because "as long
|
||||
// as the aura lasts" is exactly what a burst must not be.
|
||||
let span = dm
|
||||
.first_seq_span
|
||||
.filter(|_| !inst.looping)
|
||||
.unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
|
||||
super::spawn_spell_light(
|
||||
&mut commands,
|
||||
&dm.lights,
|
||||
entity,
|
||||
super::spell_fx::SpellLightMode::Burst { span },
|
||||
);
|
||||
//
|
||||
// MONKEY (area spell light): …unless it lands INSIDE a live area pool, in which case it
|
||||
// throws nothing. Rain of Fire emits a shard 5× a second from this very model, each of
|
||||
// which used to spawn its own 0.6 s flash: 5 lights/s stacking on one patch of ground,
|
||||
// strobing, and — before the budget's area exemption — evicting the standing pool they
|
||||
// were landing in. The area light already IS the light of that ground; a second one per
|
||||
// impact adds nothing but the flicker.
|
||||
if !inside_a_live_area(at.translation, &areas) {
|
||||
let span = dm
|
||||
.first_seq_span
|
||||
.filter(|_| !inst.looping)
|
||||
.unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
|
||||
super::spawn_spell_light(
|
||||
&mut commands,
|
||||
&dm.lights,
|
||||
entity,
|
||||
super::spell_fx::SpellLightMode::Burst { span },
|
||||
);
|
||||
}
|
||||
if !inst.looping {
|
||||
// One pass of the first sequence — the kit pipeline's completion-callback
|
||||
// stand-in (`spell_fx`'s span clock, same law).
|
||||
|
||||
@@ -250,6 +250,31 @@ pub(crate) const SPELL_BURST_SPAN: f32 = 0.6;
|
||||
/// light that outlived the visual by a second would read as a stuck effect.
|
||||
pub(crate) const SPELL_REAP_FADE: f32 = 0.25;
|
||||
|
||||
/// MONKEY (area spell light): the AREA mode's ramp — longer than [`SPELL_LIGHT_RAMP`] on purpose.
|
||||
/// The 0.1 s ramp exists to keep a FLASH from appearing on one frame; a ground effect is a thing
|
||||
/// that CATCHES, and a patch of terrain that reaches full brightness in a tenth of a second reads
|
||||
/// as a light being switched on rather than as a fire spreading over it.
|
||||
pub(crate) const SPELL_AREA_RAMP: f32 = 0.25;
|
||||
|
||||
/// MONKEY (area spell light): how fast an area light goes dark once the DynamicObject it belongs to
|
||||
/// is destroyed. Slower than the kit's [`SPELL_REAP_FADE`] because the thing ending is bigger — a
|
||||
/// 24 yd pool snapping off in a quarter second is a visible pop across a whole clearing — and still
|
||||
/// far inside the ~2 s the anchor's own teardown fade takes, so the light is long gone before the
|
||||
/// burning decal it lit has finished fading out.
|
||||
pub(crate) const SPELL_AREA_FADE: f32 = 0.4;
|
||||
|
||||
/// MONKEY (area spell light): the area light's BREATHING — depth (fraction of base) and rate (Hz).
|
||||
///
|
||||
/// Explicitly NOT [`FlameFlicker`](benilla_world::lighting::FlameFlicker), which is the fire lane's
|
||||
/// fast noisy wobble: at 8 % and 0.7 Hz this is a slow swell, under the threshold where the eye
|
||||
/// reads it as the light *changing* rather than as the fire on the ground being alive. Something is
|
||||
/// needed, though — an 8-second Flamestrike patch whose light is a perfectly constant disc is the
|
||||
/// one thing that gives the whole effect away as a light source, and the model under it is
|
||||
/// animating the entire time.
|
||||
pub(crate) const SPELL_AREA_BREATH_DEPTH: f32 = 0.08;
|
||||
/// See [`SPELL_AREA_BREATH_DEPTH`].
|
||||
pub(crate) const SPELL_AREA_BREATH_HZ: f32 = 0.7;
|
||||
|
||||
/// How many spell lights may be alive at once, across every lane. The shared point-light table is
|
||||
/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth
|
||||
/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for
|
||||
@@ -288,6 +313,53 @@ pub(crate) enum SpellLightMode {
|
||||
/// An impact / destination effect / firework shell: full at onset, gone `span` seconds later.
|
||||
/// The one shape whose *whole point* is that it does not persist.
|
||||
Burst { span: f32 },
|
||||
/// MONKEY (area spell light): a PERSISTENT GROUND EFFECT — one light for a whole
|
||||
/// DynamicObject's life (Flamestrike's burning patch, Rain of Fire's storm, Consecration's
|
||||
/// ground, a hunter's Flare). Up on the slower [`SPELL_AREA_RAMP`], held with a slow
|
||||
/// [`SPELL_AREA_BREATH_DEPTH`] swell for as long as the area object stands, out over
|
||||
/// [`SPELL_AREA_FADE`] when the server destroys it.
|
||||
///
|
||||
/// It is a mode of its own rather than a [`Self::Kit`] with a different fade because the two
|
||||
/// differ in every part: the ending is driven by a `DespawnFade` on a net entity instead of an
|
||||
/// `FxDecay` on an effect instance, the hold is modulated, and the ceiling treats it as the one
|
||||
/// spell light that must NOT be evicted (see [`budget_spell_lights`]) — a strobe of impact
|
||||
/// flashes must never be able to push the standing pool out of the table.
|
||||
///
|
||||
/// `phase` staggers the breathing per light (seeded from the anchor), so two overlapping
|
||||
/// patches swell out of step rather than pulsing the clearing in unison.
|
||||
Area { phase: f32 },
|
||||
}
|
||||
|
||||
impl SpellLightMode {
|
||||
/// Seconds this mode takes to come up.
|
||||
fn ramp(self) -> f32 {
|
||||
match self {
|
||||
Self::Area { .. } => SPELL_AREA_RAMP,
|
||||
_ => SPELL_LIGHT_RAMP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds this mode takes to go dark once its host has been reaped.
|
||||
fn reap_fade(self) -> f32 {
|
||||
match self {
|
||||
Self::Area { .. } => SPELL_AREA_FADE,
|
||||
_ => SPELL_REAP_FADE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light): the marker on a live area light, carrying the AoE footprint
|
||||
/// (`DYNAMICOBJECT_RADIUS`, yd) the wire gave it.
|
||||
///
|
||||
/// Two readers, and neither wants it on [`SpellLight`] itself:
|
||||
/// - the DEDUPE ([`super::super::dest_fx`]) — a repeating impact that lands inside a live area
|
||||
/// light's footprint spawns no light of its own, which is the whole of why Rain of Fire is one
|
||||
/// steady pool instead of five flashes a second;
|
||||
/// - the BUDGET below, which evicts area lights last.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct AreaSpellLight {
|
||||
/// The wire AoE radius (yd), horizontal — the dedupe's own test radius.
|
||||
pub(crate) radius: f32,
|
||||
}
|
||||
|
||||
/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the
|
||||
@@ -331,18 +403,33 @@ impl SpellLight {
|
||||
if t <= 0.0 {
|
||||
return 0.0; // the flame this light stands for does not exist yet
|
||||
}
|
||||
let up = (t / SPELL_LIGHT_RAMP).min(1.0);
|
||||
let ramp = self.mode.ramp();
|
||||
let up = (t / ramp).min(1.0);
|
||||
let down = match self.mode {
|
||||
// The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau
|
||||
// rather than a single-frame spike that a low frame rate could skip entirely.
|
||||
SpellLightMode::Burst { span } => {
|
||||
(1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
|
||||
(1.0 - (t - ramp).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
|
||||
}
|
||||
// MONKEY (area spell light): a hold that BREATHES. Deliberately allowed above 1.0 —
|
||||
// the swell is centred on the calibrated base, so clamping the top half would turn a
|
||||
// symmetric ±8 % into a one-sided dimming and leave every area light reading 4 %
|
||||
// darker than the rung it was filed on.
|
||||
SpellLightMode::Area { phase } => {
|
||||
1.0 + SPELL_AREA_BREATH_DEPTH
|
||||
* (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t + phase).sin()
|
||||
}
|
||||
SpellLightMode::Kit | SpellLightMode::Missile => 1.0,
|
||||
};
|
||||
let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0);
|
||||
let reap = (1.0 - self.reaped / self.mode.reap_fade()).clamp(0.0, 1.0);
|
||||
up * down * reap
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light): has this light finished going dark after its host was reaped?
|
||||
/// Only an AREA light asks — see the despawn note in [`advance_spell_lights`].
|
||||
fn reap_complete(&self) -> bool {
|
||||
self.reaped >= self.mode.reap_fade()
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (spell light): run every live spell light's envelope.
|
||||
@@ -357,17 +444,40 @@ impl SpellLight {
|
||||
/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the
|
||||
/// light's parent IS that root.
|
||||
pub(crate) fn advance_spell_lights(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut lights: Query<(&mut SpellLight, &mut WorldPointLight, Option<&ChildOf>)>,
|
||||
reaped: Query<Has<FxDecay>>,
|
||||
mut lights: Query<(
|
||||
Entity,
|
||||
&mut SpellLight,
|
||||
&mut WorldPointLight,
|
||||
Option<&ChildOf>,
|
||||
)>,
|
||||
// MONKEY (area spell light): the reap signal now has TWO sources, one per persistent mode.
|
||||
// `FxDecay` is written on an effect INSTANCE by the kit lane's own reap; `DespawnFade` is
|
||||
// written on a NET ENTITY by `SMSG_DESTROY_OBJECT` (`net::apply::objects`), which is the only
|
||||
// notice a DynamicObject's end ever gives. That the anchor fades rather than popping is what
|
||||
// makes an area fade possible at all: the child light outlives the server's destroy by the
|
||||
// ~2 s the anchor's geometry teardown takes, and needs only 0.4 of them.
|
||||
reaped: Query<(Has<FxDecay>, Has<benilla_world::model_fade::DespawnFade>)>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut light, mut point, parent) in &mut lights {
|
||||
for (entity, mut light, mut point, parent) in &mut lights {
|
||||
light.age += dt;
|
||||
if light.mode == SpellLightMode::Kit
|
||||
&& parent.is_some_and(|c| reaped.get(c.parent()).unwrap_or(false))
|
||||
{
|
||||
let host = parent.and_then(|c| reaped.get(c.parent()).ok());
|
||||
let ended = match light.mode {
|
||||
SpellLightMode::Kit => host.is_some_and(|(decay, _)| decay),
|
||||
SpellLightMode::Area { .. } => host.is_some_and(|(_, fade)| fade),
|
||||
SpellLightMode::Missile | SpellLightMode::Burst { .. } => false,
|
||||
};
|
||||
if ended {
|
||||
light.reaped += dt;
|
||||
// An area light's anchor lingers for its own teardown fade, several times longer than
|
||||
// the light's. Give the slot back the moment the pool is dark rather than holding one
|
||||
// of the 24 for a light nobody can see — the kit lane has no such gap, because its
|
||||
// instance despawns on the heels of its `FxDecay`.
|
||||
if matches!(light.mode, SpellLightMode::Area { .. }) && light.reap_complete() {
|
||||
commands.entity(entity).try_despawn();
|
||||
}
|
||||
}
|
||||
let want = light.base * light.envelope();
|
||||
// Write only on a real change: a held kit light sits at its plateau for the whole aura,
|
||||
@@ -389,14 +499,23 @@ pub(crate) fn advance_spell_lights(
|
||||
///
|
||||
/// Despawning the light alone never disturbs its effect: the light is a leaf child of the effect
|
||||
/// root and nothing reads back from it.
|
||||
pub(crate) fn budget_spell_lights(mut commands: Commands, lights: Query<(Entity, &SpellLight)>) {
|
||||
pub(crate) fn budget_spell_lights(
|
||||
mut commands: Commands,
|
||||
lights: Query<(Entity, &SpellLight, Has<AreaSpellLight>)>,
|
||||
) {
|
||||
let live = lights.iter().count();
|
||||
if live <= SPELL_LIGHTS_MAX {
|
||||
return;
|
||||
}
|
||||
let mut by_age: Vec<(Entity, f32)> = lights.iter().map(|(e, s)| (e, s.age)).collect();
|
||||
by_age.sort_by(|a, b| b.1.total_cmp(&a.1)); // oldest first
|
||||
for (light, _) in by_age.into_iter().take(live - SPELL_LIGHTS_MAX) {
|
||||
let mut order: Vec<(Entity, bool, f32)> =
|
||||
lights.iter().map(|(e, s, area)| (e, area, s.age)).collect();
|
||||
// MONKEY (area spell light): AREA lights go LAST, whatever their age — and an area light is
|
||||
// always among the oldest, because it is the only spell light that stands for seconds. Under
|
||||
// pure age ordering a Rain of Fire's own impact flashes would evict the very pool they are
|
||||
// landing in, i.e. the ceiling would delete the steady light and keep the strobe. Within each
|
||||
// class the rule is unchanged: oldest first, for the reasons above.
|
||||
order.sort_by(|a, b| a.1.cmp(&b.1).then(b.2.total_cmp(&a.2)));
|
||||
for (light, _, _) in order.into_iter().take(live - SPELL_LIGHTS_MAX) {
|
||||
commands.entity(light).try_despawn();
|
||||
}
|
||||
}
|
||||
@@ -534,6 +653,68 @@ mod tests {
|
||||
assert_eq!(reaped.envelope(), 0.0);
|
||||
}
|
||||
|
||||
/// MONKEY (area spell light) — GOLDEN: the AREA envelope's four legs, which differ from every
|
||||
/// other mode's in all four. The slower ramp, the BREATHING hold (the leg that exists so an
|
||||
/// 8-second Flamestrike patch is not a frozen disc), and the slower removal fade that the
|
||||
/// anchor's own ~2 s teardown leaves room for.
|
||||
#[test]
|
||||
fn an_area_light_ramps_slowly_breathes_and_fades_out() {
|
||||
let at = |mut l: SpellLight, age: f32| {
|
||||
l.age = age;
|
||||
l.envelope()
|
||||
};
|
||||
let area = || SpellLight::new(100.0, 0.0, SpellLightMode::Area { phase: 0.0 });
|
||||
// The swell is a factor on the WHOLE envelope, ramp included — it is what the light is
|
||||
// doing, not a decoration on the hold — so the expected values carry it.
|
||||
let swell = |t: f32| {
|
||||
1.0 + SPELL_AREA_BREATH_DEPTH
|
||||
* (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t).sin()
|
||||
};
|
||||
|
||||
// The ramp is the AREA one — at the flash ramp's 0.1 s an area light is well short of up.
|
||||
assert!(at(area(), SPELL_LIGHT_RAMP) < 0.5, "not a flash");
|
||||
let half = SPELL_AREA_RAMP * 0.5;
|
||||
assert!((at(area(), half) - 0.5 * swell(half)).abs() < 1e-3);
|
||||
assert!((at(area(), SPELL_AREA_RAMP) - swell(SPELL_AREA_RAMP)).abs() < 1e-3);
|
||||
|
||||
// The hold BREATHES: a full period later it is back at its base, a quarter period past
|
||||
// that it is at the top of the swell, and it never wanders outside ±depth. (Every mark
|
||||
// below is past the ramp, so `up` is 1 and the swell is the whole of the value.)
|
||||
let period = 1.0 / SPELL_AREA_BREATH_HZ;
|
||||
assert!((at(area(), period) - 1.0).abs() < 1e-3, "one period, back to base");
|
||||
assert!(
|
||||
(at(area(), period * 1.25) - (1.0 + SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3,
|
||||
"the swell is centred on the base, not clamped below it"
|
||||
);
|
||||
assert!(
|
||||
(at(area(), period * 1.75) - (1.0 - SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3
|
||||
);
|
||||
for step in 0..200 {
|
||||
let v = at(area(), 1.0 + step as f32 * 0.05);
|
||||
assert!(
|
||||
(1.0 - SPELL_AREA_BREATH_DEPTH - 1e-3..=1.0 + SPELL_AREA_BREATH_DEPTH + 1e-3)
|
||||
.contains(&v),
|
||||
"the hold never drifts off its base: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
// Removal: the area fade, not the kit's — and `reap_complete` is what hands the budget
|
||||
// slot back rather than waiting out the anchor's own teardown.
|
||||
let mut ending = area();
|
||||
ending.age = 8.0;
|
||||
ending.reaped = SPELL_AREA_FADE * 0.5;
|
||||
assert!(!ending.reap_complete());
|
||||
assert!(ending.envelope() < 0.55 && ending.envelope() > 0.45);
|
||||
ending.reaped = SPELL_AREA_FADE;
|
||||
assert_eq!(ending.envelope(), 0.0);
|
||||
assert!(ending.reap_complete());
|
||||
// A KIT light of the same age is NOT finished at the area fade's length — the two clocks
|
||||
// are genuinely separate.
|
||||
let mut kit_ending = SpellLight::new(1.0, 0.0, SpellLightMode::Kit);
|
||||
kit_ending.reaped = SPELL_REAP_FADE * 0.5;
|
||||
assert!(!kit_ending.reap_complete());
|
||||
}
|
||||
|
||||
fn test_anims(clips: &[AnimClip]) -> ModelAnimations {
|
||||
ModelAnimations {
|
||||
graph: Handle::default(),
|
||||
|
||||
@@ -83,9 +83,11 @@ pub(super) use lifecycle::advance_fx_anim;
|
||||
// switch and the mode each lane spawns it in. Exported at `pub(crate)` because the spawners live
|
||||
// outside this module: `entities::carried_light` holds the one spawn helper (and the firework
|
||||
// GameObject's own branch), and `entities.rs` registers the two systems.
|
||||
// MONKEY (area spell light): plus the persistent ground lane's marker, which `dest_fx` reads for
|
||||
// the impact dedupe and the budget reads to evict a standing pool last.
|
||||
pub(crate) use lifecycle::{
|
||||
advance_spell_lights, budget_spell_lights, spell_lights_enabled, SpellLight, SpellLightMode,
|
||||
SPELL_BURST_SPAN,
|
||||
advance_spell_lights, budget_spell_lights, spell_lights_enabled, AreaSpellLight, SpellLight,
|
||||
SpellLightMode, SPELL_BURST_SPAN,
|
||||
};
|
||||
use lifecycle::{decay_span, FxAnimLife, FxDecay};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
//! corruption seen during the first experiment. Only the private-layer proxy casts.
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::ecs::entity::EntityHashSet;
|
||||
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
|
||||
use bevy::light::{
|
||||
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
|
||||
@@ -45,7 +46,13 @@ use benilla_assets::materials::WowModelMaterial;
|
||||
use benilla_formats::ModelBlend;
|
||||
use benilla_world::billboard::BillboardCard;
|
||||
use benilla_world::interact::PickMesh;
|
||||
use benilla_world::lighting::{ShadowDistance, ShadowFilterGaussian, WowLighting};
|
||||
// MONKEY (moon shadows): `sun_shadow_strength` is the rig's half of the hand-over law — WHICH body
|
||||
// this one directional light is aimed at. Imported rather than mirrored so the aim and the packed
|
||||
// weight can never disagree about which map the receivers are reading.
|
||||
use benilla_world::lighting::{
|
||||
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
|
||||
ShadowDistance, ShadowFilterGaussian, WowLighting,
|
||||
};
|
||||
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
|
||||
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
|
||||
use benilla_world::view::WorldCamera;
|
||||
@@ -227,6 +234,13 @@ fn sun_snap_due(held: Option<Vec3>, live: Vec3) -> bool {
|
||||
/// celestial TO-sun direction, elevation clamped to [`MIN_SHADOW_SUN_ELEVATION`,
|
||||
/// `MAX_SHADOW_SUN_ELEVATION`]. Azimuth is preserved so shadows sweep with the real sun; only the
|
||||
/// height is tamed so a set/zenith sun can't invert or degenerate the basis.
|
||||
///
|
||||
/// MONKEY (moon shadows): the MOON is aimed through this same function, unchanged. The clamp is
|
||||
/// exactly what a moonrise needs — the white moon climbs from −10° through 0° to +55°, and an
|
||||
/// unclamped basis at 1° of elevation stretches the cascade's footprint toward the horizon until
|
||||
/// every shadow in it is a smear. The moon also has no separate azimuth problem to solve: its
|
||||
/// bearing is the SAME constant 45° the celestial sun uses (`daynight::moon_direction`), so a moon
|
||||
/// shadow falls along a midday sun shadow's compass line, only longer.
|
||||
fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
|
||||
let s = to_sun.normalize_or_zero();
|
||||
if s == Vec3::ZERO {
|
||||
@@ -259,8 +273,8 @@ struct ShadowCameraLayer {
|
||||
had_layers: bool,
|
||||
}
|
||||
|
||||
/// The two ordered phases of the shadow feature within `Last`: the rig runs FIRST (spawns/aims the
|
||||
/// sun, publishes [`ShadowFrame`]), then every lane runs in [`ShadowSet::Lanes`] — reading the frame
|
||||
/// MONKEY (moon shadows): the rig runs in PostUpdate BEFORE Propagate (spawns/aims the
|
||||
/// sun, publishes [`ShadowFrame`]), then lanes run in Last in [`ShadowSet::Lanes`] — reading the frame
|
||||
/// to build its casters and ORing its demand back in for the rig to read next frame.
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum ShadowSet {
|
||||
@@ -280,6 +294,8 @@ pub(crate) struct ShadowDemand(pub bool);
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ShadowFrame {
|
||||
pub active: bool,
|
||||
/// MONKEY (moon shadows): keep cached geometry, but spend no rebuild work on an off night.
|
||||
pub suspended: bool,
|
||||
pub light_position: Vec3,
|
||||
pub sun_direction: Vec3,
|
||||
/// Reach for short casters (creatures/gameobjects — at most a body tall).
|
||||
@@ -347,12 +363,15 @@ impl Plugin for ShadowCorePlugin {
|
||||
.init_resource::<ShadowDemand>()
|
||||
.init_resource::<ShadowFrame>()
|
||||
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
|
||||
.configure_sets(Last, (ShadowSet::Rig, ShadowSet::Lanes).chain())
|
||||
// MONKEY (moon shadows): after ALL Update work (lighting resolve + strength bridge),
|
||||
// before this frame's transform propagation and light-buffer packing. Last was too
|
||||
// late: a time jump had already packed the new body's weight over the old basis.
|
||||
.configure_sets(PostUpdate, ShadowSet::Rig.before(bevy::transform::TransformSystems::Propagate))
|
||||
// MONKEY (sun shadow perf): the quality dials land BEFORE the rig each frame, and
|
||||
// unconditionally — a `shadowMapSize` write must take even while the lanes are off, so
|
||||
// turning shadows back on doesn't render one frame at the previous size.
|
||||
.add_systems(
|
||||
Last,
|
||||
PostUpdate,
|
||||
(apply_shadow_quality, manage_rig)
|
||||
.chain()
|
||||
.in_set(ShadowSet::Rig),
|
||||
@@ -433,19 +452,24 @@ fn manage_rig(
|
||||
state: Res<State<ClientState>>,
|
||||
video: Res<VideoConfig>,
|
||||
lighting: Res<WowLighting>,
|
||||
time: Res<Time>,
|
||||
mut handover: ResMut<ShadowHandover>,
|
||||
mut demand: ResMut<ShadowDemand>,
|
||||
mut frame: ResMut<ShadowFrame>,
|
||||
mut rig: ResMut<ShadowRigState>,
|
||||
// MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so
|
||||
// `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance).
|
||||
mut shadow_distance_out: ResMut<ShadowDistance>,
|
||||
// MONKEY (moon shadows): read-only here — the settings registry owns the write. `0` keeps the
|
||||
// rig aimed at the celestial sun all night, i.e. the pre-feature behaviour exactly.
|
||||
moon_strength: Res<MoonShadowStrength>,
|
||||
mut commands: Commands,
|
||||
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
|
||||
mut cameras: Query<
|
||||
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
|
||||
With<WorldCamera>,
|
||||
>,
|
||||
mut suns: Query<&mut Transform, With<ShadowSun>>,
|
||||
mut suns: Query<(&mut Transform, &mut DirectionalLight), With<ShadowSun>>,
|
||||
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
) {
|
||||
let in_world = *state.get() == ClientState::InWorld;
|
||||
@@ -459,6 +483,13 @@ fn manage_rig(
|
||||
// (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible.
|
||||
let wanted = demand.0 && in_world;
|
||||
demand.0 = false;
|
||||
// MONKEY (moon shadows): publish zero immediately on a body change. Only the actual write
|
||||
// below acknowledges the basis; fresh spawns may need a deferred-command frame to get here.
|
||||
let sun_w = sun_shadow_strength(lighting.celestial_dir().y);
|
||||
handover.request(sun_w,
|
||||
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
|
||||
moon_strength.0, wanted, time.delta_secs());
|
||||
frame.suspended = sun_w <= 0.0 && moon_strength.0 <= 0.0;
|
||||
|
||||
if !wanted {
|
||||
if let Some(entity) = rig.sun.take() {
|
||||
@@ -485,6 +516,17 @@ fn manage_rig(
|
||||
return;
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): an externally removed light is not an acknowledged live rig.
|
||||
// Deferred first spawns have no written basis yet, so they are allowed their creation frame.
|
||||
if rig.sun_written.is_some() && rig.sun.is_some_and(|sun| !suns.contains(sun)) {
|
||||
rig.sun = None;
|
||||
rig.sun_written = None;
|
||||
*handover = ShadowHandover::default();
|
||||
handover.request(sun_w,
|
||||
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
|
||||
moon_strength.0, wanted, 0.0);
|
||||
}
|
||||
|
||||
if rig.material.is_none() {
|
||||
rig.material = Some(materials.add(ShadowCasterMaterial {}));
|
||||
}
|
||||
@@ -495,7 +537,7 @@ fn manage_rig(
|
||||
// The light exists to populate a shadow map; WoW's shader lighting stays
|
||||
// authoritative, so it must not add a second diffuse term.
|
||||
illuminance: 0.0,
|
||||
shadows_enabled: true,
|
||||
shadows_enabled: !frame.suspended,
|
||||
shadow_depth_bias: 0.02,
|
||||
shadow_normal_bias: 0.8,
|
||||
..default()
|
||||
@@ -558,8 +600,15 @@ fn manage_rig(
|
||||
.map(GlobalTransform::translation)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
|
||||
// MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
|
||||
let sun_direction = shadow_sun_travel(lighting.celestial_dir());
|
||||
// MONKEY (moon shadows): request/acknowledge rather than assuming the clock is continuous.
|
||||
// The published weight stays zero across the write and its propagation frame, including a
|
||||
// noon-to-midnight jump, login and a strength-zero-to-enabled transition at midnight.
|
||||
let aim_at_moon = handover.wanted == Some(ShadowBody::Moon);
|
||||
let sun_direction = shadow_sun_travel(if aim_at_moon {
|
||||
lighting.moon_dir()
|
||||
} else {
|
||||
lighting.celestial_dir()
|
||||
});
|
||||
// MONKEY (sun shadow perf): `shadowCasterReach` scales BOTH reaches by the same factor, after
|
||||
// the reach law rather than inside it — the law's terms (resolve range, rebuild margin, the
|
||||
// sun-elevation extension) each mean something, and a dial that rewrote one of them would
|
||||
@@ -572,15 +621,21 @@ fn manage_rig(
|
||||
|
||||
if let Some(sun) = rig.sun {
|
||||
// Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
|
||||
if sun_snap_due(rig.sun_written, sun_direction) {
|
||||
if let Ok(mut current) = suns.get_mut(sun) {
|
||||
if let Ok((mut current, mut light)) = suns.get_mut(sun) {
|
||||
if light.shadows_enabled == frame.suspended {
|
||||
light.shadows_enabled = !frame.suspended;
|
||||
}
|
||||
if handover.aimed != handover.wanted || sun_snap_due(rig.sun_written, sun_direction) {
|
||||
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
|
||||
rig.sun_written = Some(sun_direction);
|
||||
if let Some(body) = handover.wanted {
|
||||
handover.aim_written(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frame.active = true;
|
||||
frame.active = rig.sun.is_some_and(|sun| suns.contains(sun));
|
||||
frame.light_position = light_position;
|
||||
frame.sun_direction = sun_direction;
|
||||
frame.entity_reach = entity_reach;
|
||||
@@ -676,6 +731,7 @@ pub(crate) fn empty_cutout_mesh() -> Mesh {
|
||||
pub(crate) fn collect_entity_geometry(
|
||||
parts: &Query<
|
||||
(
|
||||
Entity,
|
||||
&PickMesh,
|
||||
&ModelPart,
|
||||
Option<&GlobalTransform>,
|
||||
@@ -694,9 +750,10 @@ pub(crate) fn collect_entity_geometry(
|
||||
doodad_reach: f32,
|
||||
positions: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
mut built_parts: Option<&mut EntityHashSet>,
|
||||
) -> (u32, u32) {
|
||||
let (mut admitted, mut rejected) = (0u32, 0u32);
|
||||
for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
|
||||
for (entity, pick, part, global, rig_part, occluder, _material) in parts.iter() {
|
||||
if !casts_realtime_shadow(part.kind, part.blend, want_creatures, want_environment) {
|
||||
continue;
|
||||
}
|
||||
@@ -734,7 +791,15 @@ pub(crate) fn collect_entity_geometry(
|
||||
}
|
||||
let added = positions.len() as u32 - base;
|
||||
if added != 0 {
|
||||
let before = indices.len();
|
||||
append_triangles(&pick.0.indices, added, base, indices);
|
||||
// MONKEY (moon shadows): readiness is a successful triangle append, not admission
|
||||
// or a material handle. Missing palettes / pending mesh data must keep the oval.
|
||||
if indices.len() > before {
|
||||
if let Some(built) = built_parts.as_deref_mut() {
|
||||
built.insert(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(admitted, rejected)
|
||||
@@ -850,6 +915,52 @@ mod tests {
|
||||
assert_eq!(shadow_sun_travel(Vec3::ZERO), Vec3::NEG_Z);
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the RIG's half of the hand-over — WHICH body the one directional
|
||||
/// light is aimed at, and that the swap lands where nothing is being cast.
|
||||
///
|
||||
/// `manage_rig`'s predicate is `moonShadowStrength > 0 && sun_shadow_strength(sun.y) <= 0`.
|
||||
/// What has to hold for that to be a swap and not a POP is that on the frame it flips, BOTH
|
||||
/// weights are zero — the sun's because the predicate says so, the moon's because
|
||||
/// `moon_shadow_weight` has its own elevation ramp and the moon is still under the horizon
|
||||
/// there. (The weights' own continuity over the whole game day is
|
||||
/// `benilla_world`'s `the_two_shadow_weights_never_overlap_and_neither_jumps`; this is the
|
||||
/// aiming side of the same contract, and the reason the two are not one test is that the aim
|
||||
/// lives in this crate and the weights in that one.)
|
||||
///
|
||||
/// The third assertion is the one a reviewer should look at hardest: a moon at or under the
|
||||
/// horizon must still produce a DOWNWARD, elevation-clamped basis. Unclamped, a moonrise aim
|
||||
/// either inverts (a below-horizon body's travel points up, so every shadow falls the wrong
|
||||
/// way) or stretches the cascade's footprint toward the horizon until the map resolves nothing.
|
||||
#[test]
|
||||
fn the_rig_hands_over_to_the_moon_only_where_neither_body_casts() {
|
||||
use benilla_world::lighting::{moon_shadow_weight, sun_shadow_strength};
|
||||
|
||||
// Dusk, the frame the aim flips: the sun has just reached the horizon.
|
||||
let sun_down = -0.02_f32;
|
||||
assert_eq!(sun_shadow_strength(sun_down), 0.0, "the sun casts nothing at the horizon");
|
||||
// …and the moon is still under it (the shipped tables put moonrise ~1h45m later).
|
||||
let moon_under = -0.17_f32;
|
||||
assert_eq!(
|
||||
moon_shadow_weight(sun_down, moon_under),
|
||||
0.0,
|
||||
"the moon must not start casting the instant the sun stops — the swap has to happen \
|
||||
where BOTH weights are zero or the map's contents change under a live weight"
|
||||
);
|
||||
// While the sun is still up the moon is refused outright, whatever its elevation.
|
||||
assert!(sun_shadow_strength(0.5) > 0.0);
|
||||
assert_eq!(moon_shadow_weight(0.5, 0.9), 0.0, "one body casts at a time");
|
||||
// A high midnight moon does cast, and at full weight (the strength dial scales it later).
|
||||
assert_eq!(moon_shadow_weight(sun_down, 0.82), 1.0);
|
||||
// The aim itself: a below-horizon moon still travels DOWN, pinned at the floor elevation —
|
||||
// the same treatment `shadow_sun_travel` gives a set sun.
|
||||
let t = shadow_sun_travel(Vec3::new(0.7, moon_under, 0.7).normalize());
|
||||
assert!(t.y < 0.0, "a moonrise basis still points down");
|
||||
assert!(
|
||||
((-t.y).asin() - MIN_SHADOW_SUN_ELEVATION).abs() < 1e-3,
|
||||
"a low moon is pinned to the floor elevation, so its shadows cannot smear to the horizon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
|
||||
let held = Vec3::new(0.3, -0.8, 0.52).normalize();
|
||||
|
||||
@@ -1968,6 +1968,321 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── MONKEY (advanced graphics): the Advanced Graphics page ───────────────────────────────────
|
||||
|
||||
/// The name every row on the new page is derived from.
|
||||
const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
|
||||
|
||||
#[test]
|
||||
fn advanced_graphics_fire_and_spell_sliders_read_and_write_the_full_range() {
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("fireLightGain", "3.25");
|
||||
s.set_cvar_host("spellLightGain", "3.25");
|
||||
let mut s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame) \
|
||||
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain")] {
|
||||
assert_eq!(s.eval::<f32>(&format!(
|
||||
"return {ADVGFX}{row}ControlSlider:GetValue()"
|
||||
)).unwrap(), 3.25);
|
||||
assert_eq!(s.eval::<String>(&format!(
|
||||
"return {ADVGFX}{row}ControlValue:GetText()"
|
||||
)).unwrap(), "325%");
|
||||
for value in ["4", "2.75"] {
|
||||
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})")).unwrap();
|
||||
assert!(s.take_cvar_changes().contains(&(cvar.to_string(), value.to_string())));
|
||||
assert_eq!(s.cvar(cvar).as_deref(), Some(value));
|
||||
}
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_graphics_selects_its_locale_at_load_including_yards() {
|
||||
for (locale, title, distance) in [
|
||||
("enUS", "Advanced Graphics", "80 yd"),
|
||||
("ruRU", "Расширенная графика", "80 ярд."),
|
||||
("deDE", "Advanced Graphics", "80 yd"),
|
||||
] {
|
||||
let s = audio_harness();
|
||||
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
|
||||
let s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame) \
|
||||
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>(
|
||||
"return BenillaOptionsFrameContainerTitle:GetText()"
|
||||
).unwrap(), title);
|
||||
assert_eq!(s.eval::<String>(&format!(
|
||||
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
|
||||
)).unwrap(), distance);
|
||||
assert!(s.eval::<bool>(
|
||||
"return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
|
||||
).unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
}
|
||||
|
||||
/// **The page reads the lighting CVars on select** — every control flavour on it at once, which
|
||||
/// is the point of checking one page rather than one row: this is the first page in the window
|
||||
/// carrying a dropdown, a checkbox and four sliders whose store is a benilla CVar with no
|
||||
/// reference counterpart, and the first whose labels come out of a table rather than a literal.
|
||||
///
|
||||
/// The three rows that moved here off Graphics are checked BOTH ways: present here, and gone from
|
||||
/// there. A row that was copied rather than moved would show up as two live controls over one
|
||||
/// CVar, which nothing else in this file would catch.
|
||||
#[test]
|
||||
fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("shadowMapSize", "4096");
|
||||
s.set_cvar_host("interiorShadows", "0");
|
||||
let s = harness_on(s);
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
assert!(s
|
||||
.eval::<bool>(&format!("return {ADVGFX}:IsVisible()"))
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
|
||||
.unwrap(),
|
||||
"Advanced Graphics",
|
||||
"the page title is the category row's label"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap(),
|
||||
"Defaults is live on a page with rows"
|
||||
);
|
||||
|
||||
// The dropdowns: the ladder reads the registered default, the resolution reads the seed.
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
|
||||
.unwrap(),
|
||||
"High",
|
||||
"a fresh registry is the High preset, member for member (cvars::tests)"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowShadowResolutionDropdownText:GetText()"))
|
||||
.unwrap(),
|
||||
"4096"
|
||||
);
|
||||
// The checkboxes read the table, not a restated default.
|
||||
assert!(s
|
||||
.eval::<bool>(&format!("return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"))
|
||||
.unwrap());
|
||||
assert!(!s
|
||||
.eval::<bool>(&format!("return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"))
|
||||
.unwrap());
|
||||
// The sliders, each on its own registry clamp with its own readout grammar.
|
||||
for (row, text) in [
|
||||
("RowShadowDistance", "80 yd"),
|
||||
("RowMoonShadows", "35%"),
|
||||
("RowFireFlicker", "100%"),
|
||||
("RowSpellLights", "100%"),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
text,
|
||||
"{row}"
|
||||
);
|
||||
}
|
||||
// The labels come out of the bilingual table (enUS here — benilla's `GetLocale` answers
|
||||
// nothing else yet).
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowInteriorLightLabel:GetText()"))
|
||||
.unwrap(),
|
||||
"Dynamic Interior Lighting"
|
||||
);
|
||||
|
||||
// MOVED, not copied: the Graphics page has no shadow rows left.
|
||||
for row in ["RowCharacterShadows", "RowWorldShadows", "RowShadowDistance"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return BenillaOptionsFrameContainerBodyGraphics{row} ~= nil"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} is still seated on the Graphics page"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The Lighting Quality row writes ONE CVar** — and that is the whole of its contract with this
|
||||
/// window. The preset's members are the host's to write (`cvars::LIGHTING_PRESETS`), so a page
|
||||
/// that wrote them here would be a second copy of that table, silently drifting from the one the
|
||||
/// console and the native panel use. What this pins is the boundary: one write, the engine's own
|
||||
/// spelling, and nothing else queued.
|
||||
#[test]
|
||||
fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
|
||||
// The entry click's own body (`OptionsDropdownEntry_OnClick`), which is the row's Set
|
||||
// followed by the capsule's redisplay — driven directly so the test does not depend on the
|
||||
// shared dropdown kit's list geometry.
|
||||
s.run(&format!(
|
||||
"OptionsRow_Set({ADVGFX}RowLightingQuality, \"Low\") \
|
||||
OptionsDropdown_ShowValue({ADVGFX}RowLightingQuality)"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("lightingQuality".to_string(), "Low".to_string())],
|
||||
"one write, and the value is the engine's own string — never the localised label"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
|
||||
.unwrap(),
|
||||
"Low"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The two darkness dials read backwards, and that is the bug this row shape exists to avoid.**
|
||||
/// Their store is a GAIN — `nightGain` 0.2..1.5, where 0.2 is the darkest — while the rows are
|
||||
/// named for the darkness. Left is darker either way; without the `darkness` readout the NUMBER
|
||||
/// beside the label would climb as the world got lighter, under a label that says "Darkness".
|
||||
#[test]
|
||||
fn the_darkness_sliders_read_out_so_that_left_is_darker() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
// The registered default, 0.45 of 0.2..1.5, is (1.5 − 0.45) / 1.3 = 81 % of the way dark.
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"81%"
|
||||
);
|
||||
// The darkest gain reads 100 %, the brightest 0 %; the reference night is gain 1.0.
|
||||
for (value, text) in [("0.2", "100%"), ("1.5", "0%"), ("1.0", "38%"), ("0.85", "50%")] {
|
||||
s.run(&format!(
|
||||
"{ADVGFX}RowNightDarknessControlSlider:SetValue({value})"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
text,
|
||||
"nightGain {value}"
|
||||
);
|
||||
}
|
||||
// …and the STORE keeps the gain, unchanged — the readout is a readout.
|
||||
assert!(
|
||||
s.take_cvar_changes()
|
||||
.contains(&("nightGain".to_string(), "0.85".to_string())),
|
||||
"the slider writes the gain the engine reads, not the percentage it shows"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowInteriorDarknessControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"77%",
|
||||
"interiorGain 0.5 of 0.2..1.5"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The page's dependencies**, in the reference's own shape: a master's switch greys what it
|
||||
/// gates, and the CVars keep their values throughout so turning the master back on finds its
|
||||
/// children where they were left.
|
||||
#[test]
|
||||
fn the_advanced_graphics_masters_grey_what_they_gate() {
|
||||
let s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
|
||||
// A greyed row wears the DISABLE font — 1.12's own signal, and the one `OptionsRow_SetEnabled`
|
||||
// sets on every flavour of row whatever else it does to the control. Compared as an OBJECT,
|
||||
// not a name: `GetFontObject` answers the object (`script::font`).
|
||||
let live = |s: &UiScript, row: &str| -> bool {
|
||||
s.eval::<bool>(&format!(
|
||||
"return {ADVGFX}{row}Label:GetFontObject() == GameFontNormal"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(live(&s, "RowInteriorShadows"));
|
||||
assert!(live(&s, "RowShadowResolution"));
|
||||
|
||||
// Dynamic Interior Lighting off greys the indoor torch row — the CVar's own documented
|
||||
// dependency ("needs interiorLight").
|
||||
s.run(&format!("{ADVGFX}RowInteriorLightCheck:Click()"))
|
||||
.unwrap();
|
||||
assert!(!live(&s, "RowInteriorShadows"));
|
||||
// Both sun lanes off leave nothing for the cascade rows to size.
|
||||
s.run(&format!("{ADVGFX}RowCharacterShadowsCheck:Click()"))
|
||||
.unwrap();
|
||||
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
|
||||
.unwrap();
|
||||
assert!(!live(&s, "RowShadowResolution"));
|
||||
assert!(!live(&s, "RowShadowDistance"));
|
||||
// …and one of them back on wakes them, with their values untouched.
|
||||
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
|
||||
.unwrap();
|
||||
assert!(live(&s, "RowShadowResolution"));
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowShadowDistanceControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"80 yd"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The page follows a write it did not make.** This is the one page in the window whose CVars
|
||||
/// move from outside it — the Lighting Quality row's members are written by the host a frame or
|
||||
/// two after the click, and the console and the dev lighting panel reach the same rows while the
|
||||
/// page is open. Without the poll the player would pick a preset and watch nothing below it move.
|
||||
#[test]
|
||||
fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
|
||||
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
|
||||
.unwrap();
|
||||
// The page has to be effectively visible before its OnUpdate is in the sweep at all.
|
||||
s.resolve();
|
||||
assert!(s
|
||||
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
|
||||
.unwrap());
|
||||
|
||||
// What the host does when a preset's members land: a mirror write, with no CVAR_UPDATE and no
|
||||
// Lua write behind it.
|
||||
s.set_cvar_host("exteriorShadows", "0");
|
||||
s.set_cvar_host("moonShadowStrength", "0");
|
||||
// Under the poll interval: nothing has happened yet.
|
||||
s.tick(0.1);
|
||||
assert!(s
|
||||
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
|
||||
.unwrap());
|
||||
// Past it: one refresh, and the page tells the truth again.
|
||||
s.tick(0.2);
|
||||
assert!(!s
|
||||
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {ADVGFX}RowMoonShadowsControlValue:GetText()"))
|
||||
.unwrap(),
|
||||
"0%"
|
||||
);
|
||||
// The refresh writes NOTHING back — it is a read of the table, not a round trip.
|
||||
assert!(
|
||||
s.take_cvar_changes().is_empty(),
|
||||
"the poll must never write a CVar"
|
||||
);
|
||||
// …and a settled page costs nothing: the signature has not moved, so no further refresh runs.
|
||||
s.tick(0.3);
|
||||
assert!(s.take_cvar_changes().is_empty());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Controls is the DEFAULT page and has rows since 0961: opening the window lands on it with
|
||||
/// Defaults armed, and the rows read the table — Sticky Targeting INVERTED (checked when
|
||||
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
|
||||
@@ -2560,9 +2875,66 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
"AudioRowBackgroundSound",
|
||||
),
|
||||
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
|
||||
("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
|
||||
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
|
||||
("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"),
|
||||
// MONKEY (advanced graphics): the whole Advanced Graphics page. Every row on it is
|
||||
// benilla's own mechanism — 1.12 has no realtime shadow, no fixture-lit interior and
|
||||
// no invented light — so there is no `OPTION_TOOLTIP_*` any of them could resolve,
|
||||
// and each carries a `BENILLA_` key for the same reason the four above it do. The
|
||||
// first three are the rows that MOVED here off the Graphics page: same keys, same
|
||||
// texts, new seat, which is why they read `AdvancedGraphicsRow…` now.
|
||||
(
|
||||
"BENILLA_TOOLTIP_CHARACTER_SHADOWS",
|
||||
"AdvancedGraphicsRowCharacterShadows",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_WORLD_SHADOWS",
|
||||
"AdvancedGraphicsRowWorldShadows",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_SHADOW_DISTANCE",
|
||||
"AdvancedGraphicsRowShadowDistance",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_LIGHTING_QUALITY",
|
||||
"AdvancedGraphicsRowLightingQuality",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_SHADOW_RESOLUTION",
|
||||
"AdvancedGraphicsRowShadowResolution",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_MOON_SHADOWS",
|
||||
"AdvancedGraphicsRowMoonShadows",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_INTERIOR_LIGHT",
|
||||
"AdvancedGraphicsRowInteriorLight",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_INTERIOR_SHADOWS",
|
||||
"AdvancedGraphicsRowInteriorShadows",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
|
||||
"AdvancedGraphicsRowExteriorShadows",
|
||||
),
|
||||
("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
|
||||
("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
|
||||
(
|
||||
"BENILLA_TOOLTIP_FIRE_FLICKER",
|
||||
"AdvancedGraphicsRowFireFlicker",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_SPELL_LIGHTS",
|
||||
"AdvancedGraphicsRowSpellLights",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_NIGHT_DARKNESS",
|
||||
"AdvancedGraphicsRowNightDarkness",
|
||||
),
|
||||
(
|
||||
"BENILLA_TOOLTIP_INTERIOR_DARKNESS",
|
||||
"AdvancedGraphicsRowInteriorDarkness",
|
||||
),
|
||||
];
|
||||
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
|
||||
assert_eq!(row, *want_row, "{row}: not this row's string");
|
||||
@@ -2623,7 +2995,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
|
||||
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
|
||||
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
|
||||
assert_eq!(checked, 81, "every tipped row carries a live key");
|
||||
// MONKEY (advanced graphics): +12, the rows the new page ADDS — Lighting Quality, Shadow
|
||||
// Resolution, Moon Shadows, Dynamic Interior Lighting, Indoor and Outdoor Torch Shadows,
|
||||
// Torch Shadow Softness, Fire Light Brightness, Fire Flicker, Spell Lights, Night Darkness
|
||||
// and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
|
||||
// changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
|
||||
assert_eq!(checked, 93, "every tipped row carries a live key");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
@@ -2674,6 +3051,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
"Controls",
|
||||
"Audio",
|
||||
"Graphics",
|
||||
// MONKEY (advanced graphics): the tenth category, and the one with the most
|
||||
// benilla-owned descriptions on it — every row here raises a `BENILLA_TOOLTIP_*`.
|
||||
"AdvancedGraphics",
|
||||
"Nameplates",
|
||||
"Combat",
|
||||
"Interface",
|
||||
@@ -2745,7 +3125,11 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
|
||||
// sliders.
|
||||
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
|
||||
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
|
||||
assert_eq!(raised, 81, "every row but Auto Loot raises a description");
|
||||
// MONKEY (advanced graphics): +12 for the rows the new page ADDS (the three it inherits were
|
||||
// already counted, on their old page). 81 -> 93 — and the page is where this test's own
|
||||
// teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
|
||||
// whose descriptions are all benilla's.
|
||||
assert_eq!(raised, 93, "every row but Auto Loot raises a description");
|
||||
}
|
||||
|
||||
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
|
||||
@@ -3705,7 +4089,11 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
|
||||
.split(',')
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
assert_eq!(keys.len(), 9, "the nine 1.15.9 categories: {keys:?}");
|
||||
// MONKEY (advanced graphics): ten, not nine — the era's own tree has no Advanced Graphics
|
||||
// page, because the era client has no dynamic light and shadow system to put on one. It is
|
||||
// benilla's own category, seated under Graphics in the System group, and it is held to the
|
||||
// same bar as the era's nine: it opens onto rows, and Defaults is live on it.
|
||||
assert_eq!(keys.len(), 10, "the nine 1.15.9 categories + ours: {keys:?}");
|
||||
for key in &keys {
|
||||
let has_rows = s
|
||||
.eval::<bool>(&format!(
|
||||
|
||||
@@ -267,6 +267,13 @@ pub(crate) struct VideoConfig {
|
||||
/// proxies. Trimming it is the direct dial on caster POPULATION — the input to both lanes'
|
||||
/// per-rebuild cost — at the risk of a tall caster's shadow popping in as you approach.
|
||||
pub(crate) shadow_caster_reach: f32,
|
||||
/// MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night
|
||||
/// (`moonShadowStrength`, 0..1, default 0.35 = the fragment keeps 65 % of the night
|
||||
/// directional term). Bridged to [`benilla_world::lighting::MoonShadowStrength`] by
|
||||
/// `dynamic_interior`, beside the spell gain and for the same reasons — one live `f32`, one
|
||||
/// resource, one guard. `0` is the faithful null: every consumer of the resource guards on it,
|
||||
/// so a night at 0 renders as the build before the moon lane existed.
|
||||
pub(crate) moon_shadow_strength: f32,
|
||||
/// MONKEY (dynamic interiors): WMO interiors + their props light from the room's live fixtures
|
||||
/// (`interiorLight`) instead of the baked path. The three knobs are `interiorAmbient` (base
|
||||
/// ambient, 0..1), `interiorFill` (per-fixture bounce gain, 0..2) and `interiorExposure`
|
||||
@@ -349,8 +356,8 @@ pub(crate) struct VideoConfig {
|
||||
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..4). See
|
||||
/// [`benilla_world::lighting::DynamicInteriors::debug`].
|
||||
pub(crate) interior_debug: u32,
|
||||
/// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.8** =
|
||||
/// nights 20 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
|
||||
/// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.45** =
|
||||
/// nights 55 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
|
||||
/// into the packed ambient/diffuse/specular rows on a `mix(1, gain, night_w)` ramp — so it is
|
||||
/// exactly inert while the sun is up and live the frame it changes after dark.
|
||||
pub(crate) night_gain: f32,
|
||||
@@ -411,6 +418,8 @@ impl Default for VideoConfig {
|
||||
character_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
|
||||
world_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
|
||||
shadow_caster_reach: 1.0,
|
||||
// MONKEY (moon shadows): 0.35 — a hint of a silhouette, not a daylight-hard shadow.
|
||||
moon_shadow_strength: 0.35,
|
||||
// The cvar defaults are the source of truth at load; these only stand in until then.
|
||||
interior_light: true,
|
||||
interior_ambient: 0.015,
|
||||
@@ -507,7 +516,7 @@ pub(crate) fn on_cvar(
|
||||
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
|
||||
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
|
||||
"gxwindow" => cfg.display = display_from_flag(v),
|
||||
// ── MONKEY (lighting): the dynamic light + shadow system's 29 rows ────────────────────
|
||||
// ── MONKEY (lighting): the dynamic light + shadow system's 30 rows ────────────────────
|
||||
// They live in THIS observer, and not in one of their own beside `shadow_core` /
|
||||
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
|
||||
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
|
||||
@@ -523,7 +532,7 @@ pub(crate) fn on_cvar(
|
||||
//
|
||||
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
|
||||
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
|
||||
// `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 29 pairs.
|
||||
// `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 30 pairs.
|
||||
"worldshadows" => cfg.world_shadows = ev.flag(),
|
||||
"charactershadows" => cfg.character_shadows = ev.flag(),
|
||||
"shadowdistance" => {
|
||||
@@ -557,6 +566,10 @@ pub(crate) fn on_cvar(
|
||||
*crate::shadow_core::CASTER_REACH_RANGE.end(),
|
||||
)
|
||||
}
|
||||
// MONKEY (moon shadows): the night lane's shadow darkness. 0 IS meaningful (the lane off,
|
||||
// bit-identical to the pre-feature night), so it floors at 0 rather than at a
|
||||
// minimum-useful value; 1 is a daylight-hard silhouette by moonlight.
|
||||
"moonshadowstrength" => cfg.moon_shadow_strength = v.clamp(0.0, 1.0),
|
||||
// MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
|
||||
// every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
|
||||
"interiorlight" => cfg.interior_light = ev.flag(),
|
||||
|
||||
@@ -138,6 +138,7 @@ fn update_world_shadows(
|
||||
mut world_active: ResMut<WorldShadowActive>,
|
||||
parts: Query<
|
||||
(
|
||||
Entity,
|
||||
&PickMesh,
|
||||
&ModelPart,
|
||||
Option<&GlobalTransform>,
|
||||
@@ -163,6 +164,12 @@ fn update_world_shadows(
|
||||
teardown(&mut lane, &mut commands, &mut meshes, &mut cutout_materials);
|
||||
return;
|
||||
}
|
||||
// MONKEY (moon shadows): no receiver can use the off-night map. Preserve cached geometry,
|
||||
// but suspend BOTH entity and retained-world rebuilds until a body can cast again.
|
||||
if frame.suspended {
|
||||
lane.env_rate.reset();
|
||||
return;
|
||||
}
|
||||
let Some(material) = frame.material.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -200,6 +207,7 @@ fn update_world_shadows(
|
||||
frame.tall_reach,
|
||||
&mut positions,
|
||||
&mut indices,
|
||||
None,
|
||||
);
|
||||
let tris = (indices.len() / 3) as u32;
|
||||
restore_mesh_buffers(mesh, positions, indices);
|
||||
|
||||
@@ -22,6 +22,40 @@
|
||||
// it fades in rather than popping at the resolve boundary.
|
||||
const SHADOW_EDGE_BAND: f32 = 14.0;
|
||||
|
||||
// MONKEY (moon shadows): the `fog_params.z` lane decodes. ONE SIGNED float carries BOTH directional
|
||||
// weights — `+w` the sun's, `-w` the moon's — because `global_light::moon_shadow_weight` guarantees
|
||||
// they are never both non-zero (the rig holds ONE body's depth map at a time; the receiver loop
|
||||
// below ASSIGNS, last light wins). The CPU half is `global_light::pack_shadow_lane`; KEEP THE TWO
|
||||
// ENDS IN SYNC.
|
||||
//
|
||||
// Every OTHER reader of this lane was already sign-safe, which is what made the sign free: the
|
||||
// three `ext_night_w` decodes are `clamp(1 - z, 0, 1)`, and a negative `z` gives them the same 1.0
|
||||
// the 0.0 they used to read gave them, so the exterior torch lane is bit-identical after dark.
|
||||
fn sun_shadow_w(lane: f32) -> f32 {
|
||||
return max(lane, 0.0);
|
||||
}
|
||||
|
||||
fn moon_shadow_w(lane: f32) -> f32 {
|
||||
return max(-lane, 0.0);
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): ONE map fetch, the piece both weights share. Split out of
|
||||
// `realtime_shadow` so `realtime_shadow_terms` can weight the SAME sample twice instead of
|
||||
// sampling twice — the fetch is a 9-tap Gaussian PCF and the whole feature's cost budget is "no new
|
||||
// passes, no new textures, and nothing a second time".
|
||||
fn shadow_fetch(sample_pos: vec4<f32>, normal: vec3<f32>, view_z: f32) -> f32 {
|
||||
var shadow = 1.0;
|
||||
if (lights.n_directional_lights > 0u) {
|
||||
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
|
||||
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
|
||||
shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return shadow;
|
||||
}
|
||||
|
||||
// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
|
||||
//
|
||||
// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
|
||||
@@ -43,19 +77,49 @@ fn realtime_shadow(
|
||||
shadow_range: f32,
|
||||
night: f32,
|
||||
) -> f32 {
|
||||
var shadow = 1.0;
|
||||
if (lights.n_directional_lights > 0u) {
|
||||
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
|
||||
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
|
||||
shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let shadow = shadow_fetch(sample_pos, normal, view_z);
|
||||
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
|
||||
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): BOTH directional weights over ONE fetch — `.x` the SUN arm (exactly what
|
||||
// `realtime_shadow` above returns, character for character), `.y` the MOON arm.
|
||||
//
|
||||
// The three receivers call this instead of `realtime_shadow` because they apply the two arms to
|
||||
// DIFFERENT terms, and must do so from the same sample:
|
||||
// · the SUN arm keeps its existing home in each receiver (terrain's `character_shadow_term`, the
|
||||
// model's and static_gx's `ambient + (lit − ambient) × shadow_term`), untouched;
|
||||
// · the MOON arm scales the exterior NIGHT law — see each receiver's own note for which term and
|
||||
// why point lights, torches and spell lights are outside it.
|
||||
//
|
||||
// Both arms are written as the ONE original expression rather than factored through a shared
|
||||
// `occ = (1 − shadow)·(1 − edge_fade)`: float multiply is not associative, and `.x` must be the
|
||||
// same BITS as before this function existed. At `moon == 0.0` — every daylight frame, every frame
|
||||
// with `moonShadowStrength 0`, and the ≈20:30-22:17 window with the sun down and the moon not yet
|
||||
// up — `.y` is exactly `1.0` whatever the map holds, and every receiver's moon branch is guarded on
|
||||
// exactly that, so it is not entered and the render is the pre-feature one.
|
||||
fn realtime_shadow_terms(
|
||||
sample_pos: vec4<f32>,
|
||||
normal: vec3<f32>,
|
||||
view_z: f32,
|
||||
cam_dist: f32,
|
||||
shadow_range: f32,
|
||||
night: f32,
|
||||
moon: f32,
|
||||
) -> vec2<f32> {
|
||||
// MONKEY (moon shadows): neither body casts during hand-over or feature-off night. Skip
|
||||
// the map entirely, including PCF; the old night cost must not grow with an inert moon.
|
||||
if (night <= 0.0 && moon <= 0.0) {
|
||||
return vec2<f32>(1.0);
|
||||
}
|
||||
let shadow = shadow_fetch(sample_pos, normal, view_z);
|
||||
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
|
||||
return vec2<f32>(
|
||||
1.0 - (1.0 - shadow) * night * (1.0 - edge_fade),
|
||||
1.0 - (1.0 - shadow) * moon * (1.0 - edge_fade),
|
||||
);
|
||||
}
|
||||
|
||||
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
|
||||
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
|
||||
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
|
||||
|
||||
@@ -63,7 +63,7 @@ struct WowLight {
|
||||
light_sun: vec4<f32>, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused.
|
||||
light_spec: vec4<f32>, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables.
|
||||
fog_color: vec4<f32>, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
|
||||
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
|
||||
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z = signed +sun / -moon shadow weight; w = farclip wall.
|
||||
_sh: array<vec4<f32>, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
|
||||
sh_c16: vec4<f32>, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
|
||||
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by terrain.
|
||||
@@ -281,6 +281,8 @@ struct TerrainVsOut {
|
||||
// reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
|
||||
// linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
|
||||
@location(9) base_lit: vec3<f32>,
|
||||
// MONKEY (moon shadows): preserve the point sum BEFORE saturation; primary cannot recover it.
|
||||
@location(10) point_lit: vec3<f32>,
|
||||
}
|
||||
|
||||
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
|
||||
@@ -611,6 +613,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
|
||||
let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
|
||||
out.ext_sel = sel;
|
||||
let points = point_light_eval(sel, out.world_position.xyz, n);
|
||||
out.point_lit = points;
|
||||
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
|
||||
// The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
|
||||
// back to a `primary` that does not already have the unshadowed point term baked into it.
|
||||
@@ -693,18 +696,24 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
|
||||
// MCSH fade-back.
|
||||
let view_z = (view.view_from_world * in.world_position).z;
|
||||
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
|
||||
let sun_shadow_strength = wow_light.fog_params.z;
|
||||
// MONKEY (moon shadows): the lane is SIGNED now — `+` the sun's weight, `-` the moon's (see
|
||||
// `shadow_hook::sun_shadow_w`). `max(z, 0)` is the identity on every value this lane ever held
|
||||
// before the feature, so the name, the MCSH gate and the torch-lane decode below are unchanged.
|
||||
let sun_shadow_strength = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
|
||||
let moon_shadow_strength = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
|
||||
// Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
|
||||
// below needs the same normal for its normal-offset sample.
|
||||
let n_lit = normalize(in.world_normal);
|
||||
let world_shadow = shadow_hook::realtime_shadow(
|
||||
let shadow_terms = shadow_hook::realtime_shadow_terms(
|
||||
in.world_position,
|
||||
n_lit,
|
||||
view_z,
|
||||
cam_dist,
|
||||
wow_light._wmo_fog[1].z,
|
||||
sun_shadow_strength,
|
||||
moon_shadow_strength,
|
||||
);
|
||||
let world_shadow = shadow_terms.x;
|
||||
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
|
||||
//
|
||||
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
|
||||
@@ -734,6 +743,19 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
|
||||
primary = mix(in.primary, primary_night, ext_night_w);
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): attenuate the SKY (ambient AND directional) BEFORE adding points
|
||||
// and saturating. Subtracting from primary loses over-gamut torch energy: sky .2 + point 1
|
||||
// must remain 1 under a .35 moon shadow, not .93. Keep the entire off arm above untouched;
|
||||
// neither a zero weight nor a fully lit fragment earns a different Gouraud/clamp path.
|
||||
if (shadow_terms.y < 1.0) {
|
||||
var points = in.point_lit;
|
||||
if (ext_night_w > 0.0) {
|
||||
points = mix(points,
|
||||
point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w);
|
||||
}
|
||||
primary = clamp(in.base_lit * shadow_terms.y + points, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
}
|
||||
|
||||
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
|
||||
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
|
||||
// shadowed) and uses it TWO ways (Q15) — NOT the Q11 separate ambient-tint overlay (that's the
|
||||
@@ -756,6 +778,14 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
|
||||
// world shadow fades at night the baked MCSH fades back IN. By day (strength 1) MCSH is fully
|
||||
// replaced; at night (strength 0) the authored bake returns; dusk crossfades. Character-only
|
||||
// (lane flag off) never suppresses MCSH.
|
||||
// MONKEY (moon shadows): this stays on the SUN weight alone — deliberately. The symmetric
|
||||
// reading ("a moon shadow replaces 35 % of the bake too") lightens MCSH everywhere, including
|
||||
// the ground BEYOND the cascade where the realtime lane resolves nothing and there would be no
|
||||
// replacement for what was removed. The two also compose rather than double-count: the moon
|
||||
// arm scales the light SUM (`primary`, above) and MCSH multiplies the result, and the moon arm
|
||||
// is ≤ 0.35 of a term the bake is already a flat −30 % of. The visible consequence is that a
|
||||
// tree at night wears its baked noon-sun shadow AND a faint moon-direction one — which is worth
|
||||
// eyes on a capture before it is called wrong.
|
||||
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
|
||||
let mcsh_suppress = select(0.0, sun_shadow_strength, world_shadow_lane);
|
||||
let shadow_lit_eff = mix(shadow_lit, 1.0, mcsh_suppress);
|
||||
|
||||
@@ -80,7 +80,7 @@ struct WowLight {
|
||||
_light_sun: vec4<f32>, // 2
|
||||
_light_spec: vec4<f32>, // 3
|
||||
fog_color: vec4<f32>, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
|
||||
fog_params: vec4<f32>, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z unused; w = farclip wall (0 ⇒ off)
|
||||
fog_params: vec4<f32>, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z = the signed directional-shadow weight (MONKEY moon shadows: +sun / -moon; unread here); w = farclip wall (0 ⇒ off)
|
||||
};
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> w: WowLight;
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ struct WowLight {
|
||||
light_sun: vec4<f32>, // xyz sun TRAVEL dir (to-light = −xyz); w = directional-light enable (>0.5)
|
||||
light_spec: vec4<f32>, // rgb spec color; w = shininess (terrain's — unused by the matte model path)
|
||||
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
|
||||
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
|
||||
fog_params: vec4<f32>, // x=start y=end z=signed +sun / -moon shadow weight w=farclip wall
|
||||
// The global Model2.bls SH rows (6-12): the scene day/night light as an order-2 probe at
|
||||
// intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in c10.xyz / c13 /
|
||||
// c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md). Every sun band is
|
||||
@@ -1800,9 +1800,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
// MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade) is computed by
|
||||
// `benilla::shadow_hook`. This lane keeps only the rig-skin SAMPLE-POINT choice + the
|
||||
// interior/rig exclusion + ambient-preserving apply below.
|
||||
// MONKEY (moon shadows): the NIGHT arm of the same one fetch — 1.0 (inert) by day, by feature-off
|
||||
// and in the sun-down/moon-not-yet-up window. See `shadow_hook::realtime_shadow_terms`.
|
||||
var player_shadow = 1.0;
|
||||
var player_moon = 1.0;
|
||||
let view_z = (view.view_from_world * in.world_position).z;
|
||||
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
|
||||
let sun_lane_w = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
|
||||
let moon_lane_w = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
|
||||
#ifdef WOW_RIG_SKIN
|
||||
// A skinned UNIT samples the map ONCE at its rig origin (between the feet), nudged 2.5 units
|
||||
// TOWARD the sun, and dims uniformly — the reference's per-unit response. Per-fragment sampling
|
||||
@@ -1815,24 +1820,30 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
|
||||
1.0,
|
||||
);
|
||||
player_shadow = shadow_hook::realtime_shadow(
|
||||
let terms_rig = shadow_hook::realtime_shadow_terms(
|
||||
anchor,
|
||||
vec3<f32>(0.0, 1.0, 0.0),
|
||||
view_z,
|
||||
shadow_cam_dist,
|
||||
wow_light.wmo_fog_params.z,
|
||||
wow_light.fog_params.z,
|
||||
sun_lane_w,
|
||||
moon_lane_w,
|
||||
);
|
||||
player_shadow = terms_rig.x;
|
||||
player_moon = terms_rig.y;
|
||||
}
|
||||
#else
|
||||
player_shadow = shadow_hook::realtime_shadow(
|
||||
let terms_frag = shadow_hook::realtime_shadow_terms(
|
||||
in.world_position,
|
||||
wow_normalize(in.world_normal),
|
||||
view_z,
|
||||
shadow_cam_dist,
|
||||
wow_light.wmo_fog_params.z,
|
||||
wow_light.fog_params.z,
|
||||
sun_lane_w,
|
||||
moon_lane_w,
|
||||
);
|
||||
player_shadow = terms_frag.x;
|
||||
player_moon = terms_frag.y;
|
||||
#endif
|
||||
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
|
||||
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
|
||||
@@ -1844,7 +1855,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
// arm keeps `worldShadows 0` (and a fully sunlit fragment) byte-identical: `ambient +
|
||||
// (lit − ambient) × 1.0` is not guaranteed to round back to `lit`.
|
||||
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, player_shadow);
|
||||
let lit_with_shadow = select(
|
||||
var lit_with_shadow = select(
|
||||
lit,
|
||||
clamp(
|
||||
wow_light.light_ambient.rgb + (lit - wow_light.light_ambient.rgb) * shadow_term,
|
||||
@@ -1853,6 +1864,16 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
),
|
||||
!is_interior && !is_rig && player_shadow < 0.999,
|
||||
);
|
||||
// MONKEY (moon shadows): the night sky includes ambient AND the directional/SH lobe.
|
||||
// Scale its UNCLAMPED value; points join below, then the combine saturates. A saturated
|
||||
// torch must never lose energy to the moon. Interiors/authored rigs and the exact off path
|
||||
// retain their old expressions; no multiply-by-one round trip on daylight or strength zero.
|
||||
if (player_moon < 1.0 && !is_interior && !is_rig) {
|
||||
let sky = select(wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl,
|
||||
sun_lobe, use_doodad_shade);
|
||||
lit_with_shadow = sky * player_moon;
|
||||
}
|
||||
|
||||
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
|
||||
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
|
||||
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
|
||||
|
||||
@@ -93,6 +93,33 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The AREA block (fields 11/12/13 — the DynamicObject machine, decision 0797): visual A is
|
||||
// the persistent area model the dynobj instances verbatim (gated on field 11 ≠ 0), and the
|
||||
// area kit's type-9 CharProc is the shard emitter. Printed because "what does Flamestrike
|
||||
// actually put on the ground" is unanswerable from the five unit stages above — those are the
|
||||
// CASTER's kit, and a ground effect's own model hangs off this row instead.
|
||||
println!(
|
||||
" area gate={} effect {:<5} -> {} kit {}",
|
||||
stages.area_gate,
|
||||
stages.area_effect,
|
||||
visuals
|
||||
.effect_path(stages.area_effect)
|
||||
.unwrap_or("(none/MISSING PATH)"),
|
||||
stages.area_kit,
|
||||
);
|
||||
if stages.area_kit != 0 {
|
||||
match visuals.kit(stages.area_kit) {
|
||||
Some(kit) => {
|
||||
println!(
|
||||
" areakit anim={:<12} sound={}",
|
||||
kit.anim_id.map_or("—".into(), |a| a.to_string()),
|
||||
kit.sound.map_or("—".into(), |s| s.to_string()),
|
||||
);
|
||||
crate::charprocs::print_kit_procs(&visuals, stages.area_kit, " ");
|
||||
}
|
||||
None => println!(" areakit {} (MISSING ROW)", stages.area_kit),
|
||||
}
|
||||
}
|
||||
// The missile block (phase 4): the projectile exists whenever Speed > 0; its model is
|
||||
// field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to
|
||||
// field 9's dest-attach ordinal on a live target.
|
||||
|
||||
@@ -829,7 +829,7 @@ impl SpellLightKind {
|
||||
/// After the vetoes the luminous keys run. Note `FIRE` catches `FIREWORK`, `FIRECRACKER`,
|
||||
/// `SOULFIRE`, `HELLFIRE`, `RAINOFFIRE`, `FIREBALL`, `FIREBOLT` and `FIREBLAST` in one entry,
|
||||
/// which is why the list is this short: the artists named the corpus by school already.
|
||||
const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
|
||||
const SPELL_NAME_RULES: [(&str, SpellLightKind); 33] = [
|
||||
// --- the dark schools, and the name collisions they must win ---
|
||||
("LIGHTNING", SpellLightKind::None),
|
||||
("STARFIRE", SpellLightKind::None),
|
||||
@@ -863,6 +863,19 @@ const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
|
||||
("HOLY", SpellLightKind::Holy),
|
||||
("DIVINE", SpellLightKind::Holy),
|
||||
("SMITE", SpellLightKind::Holy),
|
||||
// MONKEY (area spell light): the hunter's Flare. `Holy` names the LIGHT FAMILY, not the spell's
|
||||
// school (Flare is school 0, Physical) — a magnesium signal flare burns white-hot, and Holy is
|
||||
// this enum's warm-white. Without the rule its own ramp decides, and `spell_color`'s
|
||||
// most-saturated key on `Flare_State_Base` is the `(1.00, 0.37, 0.08)` ember the particle COOLS
|
||||
// to, not the white core it is born at, so the one spell in 1.12 that exists to be a light
|
||||
// would light the ground campfire-orange.
|
||||
//
|
||||
// The key is anchored to a PATH SEPARATOR (`\FLARE_`) rather than the bare word, because the
|
||||
// bare word over-reaches in this corpus: `SPELLS\Infernal_Flare_Rec` (a warlock summon) and
|
||||
// `SPELLS\Missile_Flare` (a generic projectile streak) both contain `FLARE` and neither is a
|
||||
// signal flare. Only `SPELLS\Flare_Cast_Base` / `SPELLS\Flare_State_Base` — the cast arc and
|
||||
// the burning ground object — start a path component with it.
|
||||
("\\FLARE_", SpellLightKind::Holy),
|
||||
("FEL", SpellLightKind::Fel),
|
||||
];
|
||||
|
||||
@@ -1112,6 +1125,129 @@ pub fn synthesize_spell_light<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// MONKEY (area spell light): the PERSISTENT GROUND EFFECT arm of the spell route.
|
||||
//
|
||||
// A ground cast's visual is not the unit-kit lane at all: the server creates a TYPEID-6
|
||||
// DynamicObject and the client instances the `SpellVisual` row's own area model on it for the
|
||||
// object's whole life (`benilla_app::entities::dest_fx`). That lane is exactly the one the spell
|
||||
// light had no shape for — it spawned a `Burst`, i.e. an impact flash, on something that stands on
|
||||
// the ground for eight seconds, and then a shard flash 5× a second on top of it.
|
||||
//
|
||||
// The three questions this section answers for that lane, and why each one needed a rule of its
|
||||
// own rather than reusing [`synthesize_spell_light`]'s answer:
|
||||
//
|
||||
// 1. **Does it light?** Half the area models author NO particle emitter whatsoever — they are flat
|
||||
// animated ground DECALS (`Spells\Flamestrike_Impact_Base` and `spells\noname_area`, Explosive
|
||||
// Trap's patch, are pure geometry). The emitter route returns `None` for them, so the burning
|
||||
// ground of a Flamestrike could never light anything. [`area_light_kind`] is the fallback
|
||||
// ladder for exactly that case.
|
||||
// 2. **What colour?** See [`area_color`] — a POOL is read for seconds where a flash is read for a
|
||||
// frame, and the holy ramps in this corpus are ember art.
|
||||
// 3. **How far?** The wire `DYNAMICOBJECT_RADIUS` is the effect's real footprint (the shard
|
||||
// emitter spreads its particles over exactly it), so an area light sizes its pool from the
|
||||
// spell's own AoE rather than from its particles ([`area_reach`]).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `Spell.dbc` field 1 `School` = **0, Physical** — a spell that has no magic school at all.
|
||||
///
|
||||
/// Called out because it is the one value that must NOT be read as "this school does not glow":
|
||||
/// a hunter's Flare, a Volley of arrows and every trap are school 0, and the three want opposite
|
||||
/// answers. School 0 therefore means "the column says nothing" ([`area_light_kind`]).
|
||||
pub const SPELL_SCHOOL_PHYSICAL: u32 = 0;
|
||||
|
||||
/// The luminous family of a `Spell.dbc` **School** index (field 1 — an index, not a mask):
|
||||
/// `1 Holy`, `2 Fire`, and the rest (Nature 3, Frost 4, Shadow 5, Arcane 6) dark.
|
||||
///
|
||||
/// This is the owner's rule read off the data instead of guessed off a filename, and it is why the
|
||||
/// area lane needs almost no new name keys: Blizzard IS frost, Hurricane IS nature, Consecration IS
|
||||
/// holy and Explosive Trap IS fire, stated by the same table the damage comes from. It is also
|
||||
/// LOCALE-PROOF where matching the spell's NAME would not be — `Spell.dbc`'s name column is
|
||||
/// translated, its school column is not.
|
||||
///
|
||||
/// No `Fel` arm: 1.12 has no fel school (a warlock's spells are Fire or Shadow), so green fire is
|
||||
/// only ever named by a model path ([`SPELL_NAME_RULES`]'s `FEL`).
|
||||
pub fn spell_school_kind(school: u32) -> SpellLightKind {
|
||||
match school {
|
||||
1 => SpellLightKind::Holy,
|
||||
2 => SpellLightKind::Fire,
|
||||
_ => SpellLightKind::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The luminous family of a persistent AREA effect — `model_path` is the area model the
|
||||
/// DynamicObject instances, `spell_school` its spell's `Spell.dbc` school index, and `hue_kind` the
|
||||
/// verdict [`synthesize_spell_light`] already reached on that model (`None` when it reached none,
|
||||
/// i.e. no emitter or a vetoed one).
|
||||
///
|
||||
/// Three sources, most-authoritative first, and the order is the design:
|
||||
///
|
||||
/// 1. **The model's own path.** An artist who called the file `Flamestrike` or `FrostTrap` has
|
||||
/// stated the answer, and stated it about the very object that will be on the ground. This arm
|
||||
/// carries the VETOES too, so `Blizzard_Impact_Base` is dark here and can never be re-lit below.
|
||||
/// 2. **The spell's school**, whenever it is a real one. This is what lets a model with a
|
||||
/// meaningless name light correctly: Explosive Trap's patch is `spells\noname_area.m2`, which
|
||||
/// names nothing and emits nothing, and only the Fire school says it burns.
|
||||
/// 3. **The model's hue**, last, because it is the only guess in the list — and by the time we
|
||||
/// reach it both the artist and the DBC have declined to answer.
|
||||
pub fn area_light_kind(
|
||||
model_path: &str,
|
||||
spell_school: u32,
|
||||
hue_kind: Option<SpellLightKind>,
|
||||
) -> SpellLightKind {
|
||||
if let Some(named) = spell_name_kind(model_path) {
|
||||
return named;
|
||||
}
|
||||
if spell_school != SPELL_SCHOOL_PHYSICAL {
|
||||
return spell_school_kind(spell_school);
|
||||
}
|
||||
hue_kind.unwrap_or(SpellLightKind::None)
|
||||
}
|
||||
|
||||
/// An area light's colour: the model's own ramp ([`spell_color`], passed in as `ramp`) — **except
|
||||
/// for the HOLY family, which always takes [`DEFAULT_HOLY`]**.
|
||||
///
|
||||
/// The exception is the area lane's alone, and it exists because a POOL is read differently from a
|
||||
/// FLASH. Both holy ground effects in 1.12 author warm DUST: Consecration's strongest key is
|
||||
/// `(1.00, 0.72, 0.17)` and Flare's is `(1.00, 0.37, 0.08)`. Taken literally, a paladin's
|
||||
/// consecrated ground and a hunter's flare both put an orange pool on the terrain — which is
|
||||
/// indistinguishable from Flamestrike's, for seconds at a time, right next to it. An impact keeps
|
||||
/// its ramp (its whole life is a frame or two and the hue is the effect's signature); a light that
|
||||
/// stands still long enough to be compared has to be the colour its school reads as.
|
||||
pub fn area_color(kind: SpellLightKind, ramp: Option<[f32; 3]>) -> [f32; 3] {
|
||||
match kind {
|
||||
SpellLightKind::Holy => DEFAULT_HOLY,
|
||||
_ => ramp.unwrap_or_else(|| kind.default_color()),
|
||||
}
|
||||
}
|
||||
|
||||
/// How high above the DynamicObject's own point an area light hangs (yd).
|
||||
///
|
||||
/// A dynobj sits ON the ground and its model is a flat decal, so a light at the object's exact
|
||||
/// position is a light INSIDE the terrain: the faithful falloff `1/(0.7d + 0.03d²)` blows up at
|
||||
/// `d → 0`, and every ground vertex within a step of the centre would clip to white while the ring
|
||||
/// two yards out got nothing. Lifting it to roughly waist height makes the pool read as a pool.
|
||||
pub const AREA_LIGHT_LIFT: f32 = 1.5;
|
||||
|
||||
/// The intensity an area light takes when its model authors **no usable emitter at all** — the
|
||||
/// decal-only population (Flamestrike, Explosive Trap). `2.0` is the shared ladder's *brazier*
|
||||
/// rung ([`spell_intensity`]), which is where an 8-yard patch of burning ground belongs: a torch
|
||||
/// (1.5) is a hand-held flame and the 3.0 bonfire rung is reserved for a forge, per the spell
|
||||
/// route's own "a spell must not out-blaze a forge".
|
||||
pub const AREA_FALLBACK_INTENSITY: f32 = 2.0;
|
||||
|
||||
/// An area light's interior reach (yd) from the wire `DYNAMICOBJECT_RADIUS`.
|
||||
///
|
||||
/// `× 1.6` because the light has to spill PAST the effect: a pool that stopped exactly at the AoE
|
||||
/// edge would draw a hard disc of lit terrain with a dark rim, where a real fire lights the ground
|
||||
/// around it more faintly than the ground under it. The floor stops a 1-yard dynobj from producing
|
||||
/// a light too small to notice, and the ceiling is the top rung of the shared reach ladder
|
||||
/// (`room_claim::m2_light_reach`) — an area effect may reach as far as a bonfire and no further,
|
||||
/// so a single spell can never out-light a building's own fixtures.
|
||||
pub fn area_reach(radius: f32) -> f32 {
|
||||
(radius * 1.6).clamp(8.0, 24.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1850,4 +1986,143 @@ mod tests {
|
||||
assert_eq!(spell_intensity(0.5, 200.0), (2.0, "brazier"));
|
||||
assert_eq!(spell_intensity(1.1, 500.0), (2.0, "brazier"));
|
||||
}
|
||||
|
||||
// -- MONKEY (area spell light) ---------------------------------------------------------
|
||||
|
||||
/// Vanilla `Spell.dbc` school indices, so the area tests below read as the spells they are.
|
||||
const PHYSICAL: u32 = 0;
|
||||
const HOLY: u32 = 1;
|
||||
const FIRE: u32 = 2;
|
||||
const NATURE: u32 = 3;
|
||||
const FROST: u32 = 4;
|
||||
|
||||
/// GOLDEN — the whole named worklist, on the REAL `SpellVisual` field-12 area models each spell
|
||||
/// actually instances on its DynamicObject (dumped with `benilla-extract spellvis <id>`; the
|
||||
/// `area` line). The third argument is what [`synthesize_spell_light`] independently reached on
|
||||
/// that model, `None` for the decal-only half that authors no emitter at all — which is the
|
||||
/// case the whole ladder exists for, and is Flamestrike's.
|
||||
#[test]
|
||||
fn the_ground_effects_that_light_and_the_ones_that_stay_dark() {
|
||||
use SpellLightKind::{Fire as F, Holy as H, None as Dark};
|
||||
|
||||
// --- lights ---
|
||||
// Flamestrike (2120): a flat animated burn decal — ZERO particle emitters, so the emitter
|
||||
// route has nothing to say and the model's own name is the only source that does.
|
||||
assert_eq!(
|
||||
area_light_kind("Spells\\Flamestrike_Impact_Base.m2", FIRE, None),
|
||||
F
|
||||
);
|
||||
// Rain of Fire (5740): five emitters, `FIRE` in the path — named and measured agree.
|
||||
assert_eq!(
|
||||
area_light_kind("Spells\\RainOfFire_Impact_Base.m2", FIRE, Some(F)),
|
||||
F
|
||||
);
|
||||
// Consecration (26573): its one emitter ramps to `(1.00, 0.72, 0.17)`, which `spell_hue_kind`
|
||||
// reads as FIRE. The school column is what corrects it — and the colour rule below is what
|
||||
// stops the paladin's ground burning orange.
|
||||
assert_eq!(
|
||||
area_light_kind("spells\\consecration_impact_base.m2", HOLY, Some(F)),
|
||||
H
|
||||
);
|
||||
// Explosive Trap (13812): the patch is `noname_area` — no emitters, and a filename that
|
||||
// names nothing. ONLY the school says it burns.
|
||||
assert_eq!(area_light_kind("spells\\noname_area.m2", FIRE, None), F);
|
||||
// Flare (1543): school 0 Physical, so the school arm must abstain; the `\FLARE_` key is
|
||||
// what makes the one spell that IS a light a light.
|
||||
assert_eq!(
|
||||
area_light_kind("spells\\flare_state_base.m2", PHYSICAL, Some(F)),
|
||||
H
|
||||
);
|
||||
|
||||
// --- dark, and each for its own reason ---
|
||||
// Blizzard (10): `BLIZZ` vetoes in the path, and the school agrees. Its shards are this
|
||||
// same model, so the whole storm stays dark.
|
||||
assert_eq!(
|
||||
area_light_kind("Spells\\Blizzard_Impact_Base.m2", FROST, None),
|
||||
Dark
|
||||
);
|
||||
// Hurricane (16914): `LIGHTNING` in `LightningStorm_Cloud_State`.
|
||||
assert_eq!(
|
||||
area_light_kind("Spells\\LightningStorm_Cloud_State.m2", NATURE, None),
|
||||
Dark
|
||||
);
|
||||
// Volley (1510): `ARCANE` in `arcaneshot_area` — and school 0, so the path veto is the
|
||||
// ONLY thing holding it. That is the ordering being pinned.
|
||||
assert_eq!(
|
||||
area_light_kind("spells\\arcaneshot_area.m2", PHYSICAL, Some(F)),
|
||||
Dark
|
||||
);
|
||||
// Frost Trap (13810), and Freezing Trap, which has no area object at all.
|
||||
assert_eq!(
|
||||
area_light_kind("spells\\frosttrap_aura.m2", FROST, None),
|
||||
Dark
|
||||
);
|
||||
// A school veto beats a warm ramp: a frost model with one red spark stays dark even where
|
||||
// its path says nothing.
|
||||
assert_eq!(area_light_kind("spells\\noname_area.m2", FROST, Some(F)), Dark);
|
||||
// …and with nothing to go on at all, dark is the answer (the reference's own behaviour).
|
||||
assert_eq!(area_light_kind("spells\\noname_area.m2", PHYSICAL, None), Dark);
|
||||
}
|
||||
|
||||
/// The `\FLARE_` key is anchored to a path separator so it takes the hunter's Flare and NOT the
|
||||
/// two unrelated models that merely contain the word — the reason it is not the bare `FLARE`.
|
||||
#[test]
|
||||
fn the_flare_key_is_anchored_to_a_path_component() {
|
||||
assert_eq!(
|
||||
spell_name_kind("SPELLS\\Flare_State_Base.m2"),
|
||||
Some(SpellLightKind::Holy)
|
||||
);
|
||||
assert_eq!(
|
||||
spell_name_kind("SPELLS\\Flare_Cast_Base.m2"),
|
||||
Some(SpellLightKind::Holy)
|
||||
);
|
||||
// A warlock summon and a generic projectile streak — neither is a signal flare, and both
|
||||
// keep whatever their own art says.
|
||||
assert_eq!(spell_name_kind("SPELLS\\Infernal_Flare_Rec.m2"), None);
|
||||
assert_eq!(spell_name_kind("SPELLS\\Missile_Flare.m2"), None);
|
||||
// `ArcaneConsecration_Impact_Base_Q` is the other collision in this corpus, and the veto
|
||||
// block's position is what resolves it — `ARCANE` is read before any luminous key.
|
||||
assert_eq!(
|
||||
spell_name_kind("Spells\\ArcaneConsecration_Impact_Base_Q.m2"),
|
||||
Some(SpellLightKind::None)
|
||||
);
|
||||
}
|
||||
|
||||
/// The colour rule: a fire pool keeps the artist's ramp, a holy pool never does.
|
||||
#[test]
|
||||
fn a_holy_pool_is_white_gold_whatever_its_dust_ramp_says() {
|
||||
let ember = Some([1.0, 0.718, 0.173]);
|
||||
assert_eq!(area_color(SpellLightKind::Fire, ember), [1.0, 0.718, 0.173]);
|
||||
assert_eq!(area_color(SpellLightKind::Holy, ember), DEFAULT_HOLY);
|
||||
// No ramp at all (the decal-only models) falls back to the family's own hue.
|
||||
assert_eq!(area_color(SpellLightKind::Fire, None), DEFAULT_WARM);
|
||||
assert_eq!(area_color(SpellLightKind::Fel, None), DEFAULT_FEL);
|
||||
assert_eq!(area_color(SpellLightKind::Holy, None), DEFAULT_HOLY);
|
||||
}
|
||||
|
||||
/// The reach ladder from the wire radius, and both clamps.
|
||||
#[test]
|
||||
fn area_reach_scales_the_wire_radius_between_its_clamps() {
|
||||
// Flamestrike / Rain of Fire ship an 8 yd radius: the pool spills ~5 yd past the AoE.
|
||||
assert!((area_reach(8.0) - 12.8).abs() < 1e-4);
|
||||
assert!((area_reach(10.0) - 16.0).abs() < 1e-4);
|
||||
// A tiny (or absent — the wire field is optional) radius still makes a visible pool…
|
||||
assert_eq!(area_reach(0.0), 8.0);
|
||||
assert_eq!(area_reach(2.0), 8.0);
|
||||
// …and nothing reaches past the shared ladder's top rung.
|
||||
assert_eq!(area_reach(20.0), 24.0);
|
||||
assert_eq!(area_reach(500.0), 24.0);
|
||||
}
|
||||
|
||||
/// The school column, read as the index it is. Only 1/2 light; 0 is "says nothing" and is
|
||||
/// handled by [`area_light_kind`], not here.
|
||||
#[test]
|
||||
fn only_holy_and_fire_schools_glow() {
|
||||
assert_eq!(spell_school_kind(0), SpellLightKind::None);
|
||||
assert_eq!(spell_school_kind(1), SpellLightKind::Holy);
|
||||
assert_eq!(spell_school_kind(2), SpellLightKind::Fire);
|
||||
for dark in [3, 4, 5, 6, 99] {
|
||||
assert_eq!(spell_school_kind(dark), SpellLightKind::None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,14 @@ pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, Synthetic
|
||||
// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
|
||||
// shadow emit nothing at all.
|
||||
pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
|
||||
// MONKEY (area spell light): the same route's PERSISTENT-GROUND-EFFECT arm — a DynamicObject's
|
||||
// area model, which is routinely a flat decal with no emitter for the rule above to read, so its
|
||||
// school comes off the model path / the spell's `Spell.dbc` school and its pool is sized from the
|
||||
// wire AoE radius.
|
||||
pub use fire_light::{
|
||||
area_color, area_light_kind, area_reach, spell_school_kind, AREA_FALLBACK_INTENSITY,
|
||||
AREA_LIGHT_LIFT,
|
||||
};
|
||||
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
|
||||
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
|
||||
// enforces and the gate the instrument prints can never be two different rules.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! rebuilds, regardless of how many tiles/models are loaded or how fast the clock moves.
|
||||
|
||||
use benilla_formats::LiquidKind;
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
|
||||
use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
|
||||
@@ -35,7 +36,11 @@ use crate::view::WorldCamera;
|
||||
/// `wow_effect.wgsl`. (`liquid.wgsl`/`wdl.wgsl` reuse the field NAMES but bind their own
|
||||
/// per-material uniforms fed by `apply_wow_lighting` — editing this layout does NOT reach them.)
|
||||
/// 0 light_ambient (w=Mod2x 1.0) · 1 light_diffuse (w=clamp on) · 2 light_sun (w=dir/SH enable) ·
|
||||
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end w=farclip) ·
|
||||
/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end
|
||||
/// w=farclip; MONKEY (moon shadows) `.z` = the SIGNED directional-shadow weight — `+sun_w`
|
||||
/// by day, `−(moon weight × moonShadowStrength)` at night, 0 when neither body casts. The
|
||||
/// two are mutually exclusive by [`moon_shadow_weight`], so one float carries both; see
|
||||
/// [`pack_shadow_lane`]) ·
|
||||
/// 6-8 sh_c10_{r,g,b} · 9-11 sh_c13_{r,g,b} · 12 sh_c16 (.w = the world-shadow flag in the
|
||||
/// integer part, MONKEY (bake floor) `interiorBakeFloor × interiorGain` in the fraction —
|
||||
/// [`BAKE_LANE_SCALE`]) ·
|
||||
@@ -603,6 +608,28 @@ impl Default for ShadowDistance {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): how dark a MOON-shadowed fragment gets at night (`moonShadowStrength`,
|
||||
/// 0..1, default 0.35), bridged from benilla-app's settings registry exactly as [`ShadowDistance`]
|
||||
/// is. `0.35` means a fully moon-shadowed fragment keeps `1 − 0.35 = 65 %` of the night sky (ambient + directional)
|
||||
/// term — a hint of a silhouette, not a daylight-hard shadow.
|
||||
///
|
||||
/// A dial rather than a constant because the honest answer to "how much light does a clear full moon
|
||||
/// throw" is *very little* (the reference client casts none at all), and the interesting range —
|
||||
/// "just enough that the world is not flat" to "stylised moonlight" — is entirely a taste question
|
||||
/// that wants to be answered with the scene on screen.
|
||||
///
|
||||
/// **`0` is the faithful null.** Every consumer guards on it (the packer refuses to hand the moon a
|
||||
/// non-zero weight, and the receivers keep their existing `< 0.999` early-out arm), so a night at
|
||||
/// `moonShadowStrength = 0` renders bit-identically to the build before this feature existed.
|
||||
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
|
||||
pub struct MoonShadowStrength(pub f32);
|
||||
|
||||
impl Default for MoonShadowStrength {
|
||||
fn default() -> Self {
|
||||
Self(0.35)
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (sun shadow perf): the live `shadowFilter` choice, bridged from benilla-app's shadow rig
|
||||
/// and extracted into the RENDER world.
|
||||
///
|
||||
@@ -675,6 +702,9 @@ pub(super) fn register(app: &mut App) {
|
||||
.init_resource::<RoomClaimTable>()
|
||||
.init_resource::<WorldShadowActive>()
|
||||
.init_resource::<ShadowDistance>()
|
||||
// MONKEY (moon shadows): the night directional-shadow strength dial (0 = today's render).
|
||||
.init_resource::<MoonShadowStrength>()
|
||||
.init_resource::<ShadowHandover>()
|
||||
// MONKEY (sun shadow perf): the live PCF choice, extracted for `static_gx`'s hand-rolled
|
||||
// pipeline (every Bevy-material receiver keys off the view component instead).
|
||||
.init_resource::<ShadowFilterGaussian>()
|
||||
@@ -761,11 +791,170 @@ pub(super) fn per_frame_blob_bytes() -> u64 {
|
||||
/// MONKEY (night fade): realtime-shadow day strength from the celestial sun's height
|
||||
/// (`sin(elevation)`): 0 at or below the horizon, ramping to 1 by ~12° so shadows fade out at dusk
|
||||
/// and in at dawn. A smoothstep for a soft knee rather than a hard switch at the horizon.
|
||||
fn sun_shadow_strength(sun_height: f32) -> f32 {
|
||||
/// MONKEY (moon shadows): `pub` because the SHADOW RIG needs the same verdict the packer reaches.
|
||||
/// The rig (`benilla_app::shadow_core::manage_rig`) re-aims the one directional light at the moon
|
||||
/// exactly when this is 0, and the packer hands the moon a weight exactly when this is 0 — two
|
||||
/// copies of that threshold would be two chances to disagree about which body the map holds.
|
||||
/// (`blob_shadow.rs` keeps its own three-line mirror, pinned by its own test; that one predates
|
||||
/// this export and is deliberately self-contained.)
|
||||
pub fn sun_shadow_strength(sun_height: f32) -> f32 {
|
||||
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the NIGHT half of the one shadow rig's HAND-OVER LAW, in `[0,1]`.
|
||||
///
|
||||
/// `sun_height` / `moon_height` are `celestial_dir.y` / `moon_dir.y` — the SINE of each body's
|
||||
/// elevation, Bevy space. Returns how much the moon is allowed to cast, BEFORE
|
||||
/// [`MoonShadowStrength`] scales it.
|
||||
///
|
||||
/// **Why a hard gate and not a crossfade.** benilla's receivers sample ONE shadow-mapped
|
||||
/// directional light (the loop in `shadow_hook.wgsl` ASSIGNS — last light wins), so at any instant
|
||||
/// the map holds exactly one body's depth. A weight that overlapped the sun's would be a weight
|
||||
/// applied to the WRONG body's map for the duration of the overlap. So the moon's weight is
|
||||
/// strictly zero while the sun still has any, and the rig re-aims in the window where BOTH are
|
||||
/// zero — `sun_shadow_strength` is exactly 0 at and below the horizon, and this is exactly 0 until
|
||||
/// the moon clears the same 12° ramp.
|
||||
///
|
||||
/// **The gate never actually clips anything**, which is what makes it continuous rather than a
|
||||
/// step: with the shipped `DayNight` tables the celestial sun crosses the horizon at ≈20:30 and the
|
||||
/// white moon does not clear it until ≈22:17, so `sun_w` and the moon's elevation ramp are never
|
||||
/// both positive (asserted over the whole game day by
|
||||
/// `the_two_shadow_weights_never_overlap_and_neither_jumps`). The gate is therefore an INVARIANT
|
||||
/// guard, not a shaping term: it keeps the single-light rule true even if a zone or a retuned table
|
||||
/// ever put the two in the sky together.
|
||||
///
|
||||
/// The elevation ramp is [`sun_shadow_strength`]'s own curve, evaluated on the moon's height —
|
||||
/// deliberately the same smoothstep over the same 0..sin(12°) band, so a body low on the horizon
|
||||
/// casts nothing (which is also what keeps a moonrise shadow from stretching to infinity; the RIG
|
||||
/// clamps the basis at 18° for the same reason, `shadow_core::MIN_SHADOW_SUN_ELEVATION`).
|
||||
pub fn moon_shadow_weight(sun_height: f32, moon_height: f32) -> f32 {
|
||||
if sun_shadow_strength(sun_height) > 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
sun_shadow_strength(moon_height)
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): one map, one acknowledged body. The app requests the current clock's
|
||||
/// body before transform propagation, acknowledges an actual Transform write, then holds zero
|
||||
/// through the next frame. Both receivers and blobs read `weight`, never the desired clock weight.
|
||||
/// This makes a time jump / midnight enable as safe as the naturally dark dawn/dusk interval.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ShadowBody {
|
||||
Sun,
|
||||
Moon,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ShadowHandover {
|
||||
pub aimed: Option<ShadowBody>,
|
||||
pub wanted: Option<ShadowBody>,
|
||||
pub weight: f32,
|
||||
settling: bool,
|
||||
ramp: f32,
|
||||
}
|
||||
|
||||
impl ShadowHandover {
|
||||
/// Called ONCE per frame, after lighting resolve and the settings bridge. The rig runs before
|
||||
/// Propagate; an acknowledgement from its preceding invocation has therefore propagated now.
|
||||
pub fn request(&mut self, sun: f32, moon: f32, strength: f32, active: bool, dt: f32) {
|
||||
let wanted = active.then_some(if strength > 0.0 && sun <= 0.0 {
|
||||
ShadowBody::Moon
|
||||
} else {
|
||||
ShadowBody::Sun
|
||||
});
|
||||
if wanted != self.wanted || !active {
|
||||
self.wanted = wanted;
|
||||
self.ramp = 0.0;
|
||||
self.weight = 0.0;
|
||||
}
|
||||
if !active {
|
||||
self.aimed = None;
|
||||
self.settling = false;
|
||||
return;
|
||||
}
|
||||
if self.aimed != wanted {
|
||||
self.weight = 0.0;
|
||||
return;
|
||||
}
|
||||
if self.settling {
|
||||
self.settling = false;
|
||||
self.weight = 0.0;
|
||||
return;
|
||||
}
|
||||
// Fixed envelope: 1.5 seconds from zero to full, independent of frame rate/strength.
|
||||
self.ramp = (self.ramp + dt.max(0.0) / 1.5).min(1.0);
|
||||
self.weight = match wanted {
|
||||
Some(ShadowBody::Sun) => pack_shadow_lane(sun * self.ramp, 0.0),
|
||||
Some(ShadowBody::Moon) => pack_shadow_lane(0.0, moon * strength.clamp(0.0, 1.0) * self.ramp),
|
||||
None => 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Only the rig may acknowledge; choosing a body without writing a transform is not ready.
|
||||
pub fn aim_written(&mut self, body: ShadowBody) {
|
||||
if self.aimed != Some(body) {
|
||||
self.aimed = Some(body);
|
||||
self.settling = true;
|
||||
self.ramp = 0.0;
|
||||
self.weight = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the three realtime-shadow dials [`build_light_data`] packs, as ONE
|
||||
/// system param.
|
||||
///
|
||||
/// A bundle rather than three `Res` arguments because the packer had reached Bevy's **16-param
|
||||
/// ceiling** — `MoonShadowStrength` was the seventeenth, and the failure is not a helpful one
|
||||
/// (`.chain()` reports that a trait bound is unsatisfied on the system TUPLE, naming neither the
|
||||
/// system nor the limit). Grouping them is also the honest shape: all three are bridged from the
|
||||
/// app's shadow rig, all three ride packed lanes, and a fourth shadow dial now has somewhere to go
|
||||
/// that does not cost the next reader an afternoon.
|
||||
#[derive(SystemParam)]
|
||||
pub(super) struct ShadowLanes<'w> {
|
||||
/// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
|
||||
world_active: Res<'w, WorldShadowActive>,
|
||||
/// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
|
||||
distance: Res<'w, ShadowDistance>,
|
||||
/// MONKEY (moon shadows): the acknowledged signed weight, shared with the rig and blob.
|
||||
handover: Res<'w, ShadowHandover>,
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
|
||||
/// directional-shadow weights.
|
||||
///
|
||||
/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
|
||||
/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
|
||||
/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
|
||||
/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
|
||||
/// the two weights are **never both non-zero**, so they can share a single lane by SIGN — the
|
||||
/// cheapest possible packing, exact in both directions, with no quantisation and no cliff.
|
||||
///
|
||||
/// `+w` = the sun's weight, `-w` = the moon's (already scaled by [`MoonShadowStrength`]), `0` =
|
||||
/// neither (the ≈20:30-22:17 window when the sun has set and the moon has not risen — and every
|
||||
/// frame of a build with `moonShadowStrength 0`).
|
||||
///
|
||||
/// **The shader end** decodes with `max(z, 0)` / `max(-z, 0)` (`shadow_hook.wgsl`'s `sun_shadow_w`
|
||||
/// / `moon_shadow_w`). Every pre-existing reader of this lane is either one of those two calls or
|
||||
/// already clamped — `clamp(1 - z, 0, 1)` (the three `ext_night_w` decodes) returns 1 for any
|
||||
/// negative `z`, which is the same 1 it returned for the 0 that used to be there, so the exterior
|
||||
/// torch lane is bit-identical under the new sign. **Keep in sync with those two WGSL functions.**
|
||||
pub fn pack_shadow_lane(sun_w: f32, moon_w: f32) -> f32 {
|
||||
debug_assert!(
|
||||
!(sun_w > 0.0 && moon_w > 0.0),
|
||||
"the shadow rig holds ONE body's map: sun {sun_w} and moon {moon_w} cannot both cast"
|
||||
);
|
||||
sun_w - moon_w
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the SHADER's two decodes, transcribed. Only exist for the round-trip test
|
||||
/// below; the real decodes live in `shadow_hook.wgsl`.
|
||||
#[cfg(test)]
|
||||
fn unpack_shadow_lane(w: f32) -> (f32, f32) {
|
||||
(w.max(0.0), (-w).max(0.0))
|
||||
}
|
||||
|
||||
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
|
||||
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
|
||||
///
|
||||
@@ -1178,10 +1367,8 @@ fn build_light_data(
|
||||
// disagree about which entry is which.
|
||||
mut claims: ResMut<RoomClaimTable>,
|
||||
time: Res<Time>,
|
||||
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
|
||||
world_shadow: Res<WorldShadowActive>,
|
||||
// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
|
||||
shadow_distance: Res<ShadowDistance>,
|
||||
// The three realtime-shadow dials, bundled (see [`ShadowLanes`]).
|
||||
shadow: ShadowLanes,
|
||||
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
|
||||
dynamic_interiors: Res<DynamicInteriors>,
|
||||
// MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
|
||||
@@ -1264,7 +1451,7 @@ fn build_light_data(
|
||||
// time in exactly this way, three rows down). See [`BAKE_LANE_SCALE`] for why a fraction is
|
||||
// invisible to the one `> 0.5` decode this lane has, and why the product is clamped first.
|
||||
rows[12][3] = pack_bake_lane(
|
||||
world_shadow.0,
|
||||
shadow.world_active.0,
|
||||
dynamic_interiors.bake_floor,
|
||||
dynamic_interiors.interior_gain,
|
||||
);
|
||||
@@ -1274,11 +1461,14 @@ fn build_light_data(
|
||||
// sun still casts the right DIRECTION.
|
||||
// MONKEY (darkness gains): computed once above — `nightGain` rides this exact same curve, so
|
||||
// the dim and the shadow fade can never drift onto two different dusk clocks.
|
||||
rows[5][2] = sun_w;
|
||||
// MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
|
||||
// The body may have changed abruptly this frame; the shared state holds zero until the new
|
||||
// basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
|
||||
rows[5][2] = shadow.handover.weight;
|
||||
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
|
||||
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
|
||||
// shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
|
||||
rows[19][2] = shadow_distance.0;
|
||||
rows[19][2] = shadow.distance.0;
|
||||
// MONKEY (dynamic interiors): the lane's on/off into the free `wmo_fog_params.w` lane (1 = WMO
|
||||
// interior surfaces + props light from the room's live fixtures, 0 = the faithful baked path).
|
||||
// Its three knobs ride `point_count.yzw`, packed with the table below.
|
||||
@@ -1745,6 +1935,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): a CPU mirror of the receivers' order, including the exact off arm.
|
||||
fn moon_combine(sky: f32, point: f32, strength: f32, shadow: f32) -> f32 {
|
||||
let factor = 1.0 - strength * (1.0 - shadow);
|
||||
if factor < 1.0 {
|
||||
(sky * factor + point).clamp(0.0, 1.0)
|
||||
} else {
|
||||
(sky + point).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moon_shadows_preserve_saturated_points_and_attenuate_the_whole_sky() {
|
||||
assert_eq!(moon_combine(0.2, 1.0, 0.35, 0.0), 1.0);
|
||||
assert_eq!(moon_combine(0.2, 0.0, 0.35, 0.0), 0.2 * (1.0 - 0.35));
|
||||
for (sky, point) in [(0.2f32, 0.0f32), (0.2, 1.0), (1.2, 0.1)] {
|
||||
assert_eq!(moon_combine(sky, point, 0.0, 0.0).to_bits(),
|
||||
(sky + point).clamp(0.0, 1.0).to_bits());
|
||||
assert_eq!(moon_combine(sky, point, 0.35, 1.0).to_bits(),
|
||||
(sky + point).clamp(0.0, 1.0).to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
// MONKEY (moon shadows): exercise the REAL state machine, including delayed acknowledgement
|
||||
// (new rig's deferred spawn), the propagation hold, and frame-rate-independent monotone ramp.
|
||||
fn assert_shadow_transition(state: &mut ShadowHandover, body: ShadowBody, strength: f32) {
|
||||
let (sun, moon) = if body == ShadowBody::Sun { (1.0, 0.0) } else { (0.0, 1.0) };
|
||||
for _ in 0..3 {
|
||||
state.request(sun, moon, strength, true, 1.0 / 60.0);
|
||||
assert_eq!(state.weight, 0.0, "no acknowledgement: wrong/absent basis must not cast");
|
||||
}
|
||||
state.aim_written(body);
|
||||
assert_eq!(state.weight, 0.0, "transform-write frame");
|
||||
state.request(sun, moon, strength, true, 1.0 / 60.0);
|
||||
assert_eq!(state.weight, 0.0, "propagation hold frame");
|
||||
let mut previous = 0.0;
|
||||
for _ in 0..91 {
|
||||
state.request(sun, moon, strength, true, 1.0 / 60.0);
|
||||
assert_eq!(state.aimed, state.wanted);
|
||||
assert!(state.weight.abs() >= previous, "monotone hand-over ramp");
|
||||
assert!((state.weight.abs() - previous) <= 1.0 / 90.0 + 1e-6);
|
||||
previous = state.weight.abs();
|
||||
}
|
||||
assert_eq!(state.weight, if body == ShadowBody::Sun { 1.0 } else { -strength });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moon_shadow_clock_jumps_wait_for_the_right_basis() {
|
||||
let mut state = ShadowHandover::default();
|
||||
assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35);
|
||||
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // noon -> midnight
|
||||
assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35); // midnight -> noon
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moon_shadow_midnight_login_and_enable_wait_for_the_rig() {
|
||||
let mut state = ShadowHandover::default();
|
||||
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // login at midnight
|
||||
state.request(0.0, 1.0, 0.35, false, 1.0);
|
||||
assert_eq!(state.weight, 0.0);
|
||||
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // enable rig at midnight
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moon_shadow_strength_zero_to_enabled_waits_for_the_moon() {
|
||||
let mut state = ShadowHandover::default();
|
||||
state.request(0.0, 1.0, 0.0, true, 0.0);
|
||||
state.aim_written(ShadowBody::Sun);
|
||||
state.request(0.0, 1.0, 0.0, true, 0.0);
|
||||
state.request(0.0, 1.0, 0.0, true, 2.0);
|
||||
assert_eq!(state.weight, 0.0);
|
||||
assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35);
|
||||
}
|
||||
|
||||
/// GOLDEN — the **live exterior M2 response** (0803): `wow_model.wgsl`'s doodad/entity lane must
|
||||
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
|
||||
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
|
||||
@@ -1885,6 +2148,9 @@ mod tests {
|
||||
.init_resource::<Time>()
|
||||
.init_resource::<WorldShadowActive>()
|
||||
.init_resource::<ShadowDistance>()
|
||||
// MONKEY (moon shadows): the packer's sixth dial resource.
|
||||
.init_resource::<MoonShadowStrength>()
|
||||
.init_resource::<ShadowHandover>()
|
||||
.init_resource::<DynamicInteriors>()
|
||||
.init_resource::<FireLightGain>()
|
||||
.init_resource::<SpellLightGain>()
|
||||
@@ -2595,6 +2861,9 @@ mod tests {
|
||||
celestial_dir: Vec3::new(0.0, celestial_y, 0.0),
|
||||
..base
|
||||
});
|
||||
// MONKEY (moon shadows): this gain test models an already-settled sun rig; the
|
||||
// packer now consumes its publication instead of manufacturing a clock-only weight.
|
||||
app.world_mut().resource_mut::<ShadowHandover>().weight = sun_shadow_strength(celestial_y);
|
||||
// An EXTERIOR fire, to prove the dial stops at the sky law.
|
||||
app.world_mut().spawn((
|
||||
crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0),
|
||||
@@ -2627,6 +2896,194 @@ mod tests {
|
||||
assert!((night.points[1][0] - 2.0).abs() < 1e-4, "…at its authored value");
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (moon shadows): THE HAND-OVER LAW, swept over the whole game day against
|
||||
/// the REAL `DayNight` tables.
|
||||
///
|
||||
/// Four properties, and each one is a way the feature breaks in a manner a screenshot cannot
|
||||
/// diagnose:
|
||||
///
|
||||
/// 1. **Never both.** benilla has exactly ONE shadow-mapped directional light (the receivers
|
||||
/// ASSIGN over the light loop — last one wins), so the map holds one body's depth at a time.
|
||||
/// Two non-zero weights at any minute would mean one of them was being applied to the OTHER
|
||||
/// body's map, which renders as a shadow pointing the wrong way rather than as an error.
|
||||
/// 2. **The gate never clips.** The moon's weight is hard-gated to zero while the sun has any
|
||||
/// ([`moon_shadow_weight`]), and a hard gate biting on a non-zero value would be a STEP.
|
||||
/// This asserts the gate is inert with the shipped tables — the moon's own elevation ramp is
|
||||
/// already 0 everywhere the sun's is positive — so the gate is an invariant guard, not a
|
||||
/// shaping term.
|
||||
/// 3. **Continuity.** No minute-to-minute jump in either weight, at dusk or at dawn. The
|
||||
/// threshold is deliberately loose (0.1 per game minute): the point is "no pop", not a
|
||||
/// derivative bound, and the smoothsteps are far smoother than that.
|
||||
/// 4. **The window is real and it is the blob's.** Between the sun's set and the moon's rise
|
||||
/// NEITHER casts. That is not a bug to be crossfaded away — the moon is genuinely under the
|
||||
/// horizon then — and the oval blob covers it at full strength (`blob_shadow::blob_weight`
|
||||
/// of a zero lane is 1.0). The test pins the window's existence so a future table edit that
|
||||
/// closed or inverted it has to say so here.
|
||||
#[test]
|
||||
fn the_two_shadow_weights_never_overlap_and_neither_jumps() {
|
||||
let sample = |minute: f32| {
|
||||
let sun_h = super::super::daynight::celestial_sun_direction(minute).y;
|
||||
let moon_h = super::super::daynight::moon_direction(minute).y;
|
||||
(
|
||||
sun_shadow_strength(sun_h),
|
||||
moon_shadow_weight(sun_h, moon_h),
|
||||
// The moon's ramp BEFORE the gate — property 2 needs to see what the gate hid.
|
||||
sun_shadow_strength(moon_h),
|
||||
)
|
||||
};
|
||||
let (mut prev_sun, mut prev_moon, _) = sample(0.0);
|
||||
let (mut dark_minutes, mut moon_minutes, mut sun_minutes) = (0u32, 0u32, 0u32);
|
||||
for m in 1..=1440 {
|
||||
let (sun, moon, ungated) = sample(m as f32);
|
||||
assert!(
|
||||
!(sun > 0.0 && moon > 0.0),
|
||||
"minute {m}: both bodies cast (sun {sun}, moon {moon}) — the rig holds ONE map"
|
||||
);
|
||||
assert!(
|
||||
!(sun > 0.0 && ungated > 0.0),
|
||||
"minute {m}: the moon's elevation ramp ({ungated}) is live while the sun's is \
|
||||
({sun}) — the hand-over gate is now CLIPPING a non-zero value, i.e. it has become \
|
||||
a step in the weight rather than a guard on the invariant"
|
||||
);
|
||||
assert!(
|
||||
(sun - prev_sun).abs() < 0.1,
|
||||
"minute {m}: the sun weight jumped {prev_sun} -> {sun}"
|
||||
);
|
||||
assert!(
|
||||
(moon - prev_moon).abs() < 0.1,
|
||||
"minute {m}: the moon weight jumped {prev_moon} -> {moon}"
|
||||
);
|
||||
if sun > 0.0 {
|
||||
sun_minutes += 1;
|
||||
} else if moon > 0.0 {
|
||||
moon_minutes += 1;
|
||||
} else {
|
||||
dark_minutes += 1;
|
||||
}
|
||||
prev_sun = sun;
|
||||
prev_moon = moon;
|
||||
}
|
||||
assert!(sun_minutes > 600, "the sun should cast most of the day: {sun_minutes} min");
|
||||
assert!(moon_minutes > 200, "the moon should cast most of the night: {moon_minutes} min");
|
||||
assert!(
|
||||
dark_minutes > 60,
|
||||
"the sun sets ~1h45m before the moon rises — the oval blob owns that window, and a \
|
||||
zero here would mean the two bodies had been made to overlap: {dark_minutes} min"
|
||||
);
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the SHADER end of the signed lane, asserted against the shader TEXT.
|
||||
///
|
||||
/// [`pack_shadow_lane`] and `shadow_hook.wgsl`'s `sun_shadow_w` / `moon_shadow_w` are two
|
||||
/// declarations of ONE encoding, and the failure mode of a drift is not a build error: a
|
||||
/// receiver that went back to reading `fog_params.z` raw would take a NEGATIVE sun weight at
|
||||
/// night and BRIGHTEN every shadowed fragment (`1 − occ·negative > 1`), which reads as a
|
||||
/// glowing patch under a tree, not as a broken decode. `static_gx.wgsl` is this crate's own
|
||||
/// receiver, so it is the copy a `benilla-world` test can see; `terrain.wgsl` and
|
||||
/// `wow_model.wgsl` are `benilla-assets`' and are covered by the naga probes.
|
||||
#[test]
|
||||
fn the_static_gx_receiver_decodes_the_signed_shadow_lane() {
|
||||
let src = include_str!("../shaders/static_gx.wgsl");
|
||||
for call in [
|
||||
"shadow_hook::sun_shadow_w(wow_light.fog_params.z)",
|
||||
"shadow_hook::moon_shadow_w(wow_light.fog_params.z)",
|
||||
] {
|
||||
assert!(
|
||||
src.contains(call),
|
||||
"static_gx.wgsl no longer decodes the signed shadow lane through `{call}` — a raw \
|
||||
read of `fog_params.z` takes the MOON's negative weight as the sun's and brightens \
|
||||
what it should darken"
|
||||
);
|
||||
}
|
||||
// …and the moon arm is still behind its early-out, which is the whole `moonShadowStrength 0`
|
||||
// ⇒ bit-identical-night contract on this receiver.
|
||||
assert!(
|
||||
src.contains("if (world_moon < 1.0) {"),
|
||||
"static_gx.wgsl's moon arm lost its `world_moon < 1.0` guard — every daylight and \
|
||||
feature-off fragment now pays for (and may round through) the night subtraction"
|
||||
);
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (moon shadows): the SIGNED `fog_params.z` pack, both ends.
|
||||
///
|
||||
/// The lane is one float carrying two mutually-exclusive weights by sign, so the round trip has
|
||||
/// to be exact in both directions — a lossy pack here is a shadow that is silently the wrong
|
||||
/// strength, or (worse) a moon weight leaking into the day arm. [`unpack_shadow_lane`] is the
|
||||
/// transcription of `shadow_hook.wgsl`'s two decodes; if the WGSL and this drift, the assertion
|
||||
/// that a moon frame reads zero on the sun's decode is the one that fires.
|
||||
#[test]
|
||||
fn the_signed_shadow_lane_round_trips_both_weights() {
|
||||
for sun in [0.0f32, 0.25, 0.5, 1.0] {
|
||||
assert_eq!(unpack_shadow_lane(pack_shadow_lane(sun, 0.0)), (sun, 0.0));
|
||||
}
|
||||
for moon in [0.0f32, 0.12, 0.35, 1.0] {
|
||||
assert_eq!(unpack_shadow_lane(pack_shadow_lane(0.0, moon)), (0.0, moon));
|
||||
}
|
||||
// The pre-feature bits: a zero moon weight leaves the lane EXACTLY the sun's own value, so
|
||||
// a `moonShadowStrength 0` build packs the float it always packed.
|
||||
assert_eq!(pack_shadow_lane(1.0, 0.0), 1.0);
|
||||
assert_eq!(pack_shadow_lane(0.0, 0.0), 0.0);
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (moon shadows): the dial's two ends, through the REAL packer.
|
||||
///
|
||||
/// `moonShadowStrength 0` must pack the byte-identical night frame the build before this
|
||||
/// feature packed — that is the whole "guard it like the existing `world_shadow < 0.999` arm"
|
||||
/// contract, and it is what stands between a faint moon shadow and a renderer whose night rows
|
||||
/// no longer match three rounds of shading forensics. The non-zero arm then proves the strength
|
||||
/// is folded CPU-side (so the receivers carry no extra uniform) and lands NEGATIVE, where the
|
||||
/// sun's own decode (`max(z, 0)`) reads zero out of it.
|
||||
#[test]
|
||||
fn the_moon_strength_dial_is_packed_cpu_side_and_zero_restores_the_old_night() {
|
||||
// Midnight: the sun is 10 degrees under, the white moon is overhead (+55 degrees).
|
||||
let sun_h = super::super::daynight::celestial_sun_direction(0.0).y;
|
||||
let moon_h = super::super::daynight::moon_direction(0.0).y;
|
||||
assert!(sun_h < 0.0 && moon_h > 0.3, "midnight: sun down, moon high ({sun_h}, {moon_h})");
|
||||
let pack = |strength: f32| {
|
||||
let mut app = packer_app();
|
||||
app.world_mut().insert_resource(WowLighting {
|
||||
celestial_dir: Vec3::new(0.0, sun_h, 0.0),
|
||||
moon_dir_white: Vec3::new(0.0, moon_h, 0.0),
|
||||
..default()
|
||||
});
|
||||
app.world_mut().insert_resource(MoonShadowStrength(strength));
|
||||
let mut handover = app.world_mut().resource_mut::<ShadowHandover>();
|
||||
handover.request(0.0, 1.0, strength, true, 0.0);
|
||||
handover.aim_written(if strength > 0.0 { ShadowBody::Moon } else { ShadowBody::Sun });
|
||||
handover.request(0.0, 1.0, strength, true, 0.0);
|
||||
handover.request(0.0, 1.0, strength, true, 1.5);
|
||||
app.update();
|
||||
app.world().resource::<WowLightData>().0
|
||||
};
|
||||
let off = pack(0.0);
|
||||
assert_eq!(
|
||||
off.rows[5][2], 0.0,
|
||||
"moonShadowStrength 0 must pack the pre-feature night lane, to the bit"
|
||||
);
|
||||
let on = pack(MoonShadowStrength::default().0);
|
||||
assert_eq!(
|
||||
on.rows[5][2], -0.35,
|
||||
"a high moon at the shipped strength packs the FULL weight, negated"
|
||||
);
|
||||
assert_eq!(
|
||||
unpack_shadow_lane(on.rows[5][2]),
|
||||
(0.0, 0.35),
|
||||
"the sun's decode reads zero out of a moon frame"
|
||||
);
|
||||
// Every other row is the same night frame either way — the dial reaches ONE lane.
|
||||
for row in 0..LIGHT_HEADER_ROWS {
|
||||
for c in 0..4 {
|
||||
if (row, c) == (5, 2) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
on.rows[row][c], off.rows[row][c],
|
||||
"row {row}.{c} moved with moonShadowStrength — the dial owns ONE lane"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GOLDEN — MONKEY (darkness gains): `interiorGain` scales all THREE of the room lane's inputs
|
||||
/// and nothing on the exterior lane.
|
||||
///
|
||||
|
||||
@@ -41,6 +41,13 @@ pub use global_light::{
|
||||
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
|
||||
WorldShadowActive,
|
||||
};
|
||||
// MONKEY (moon shadows): the night directional-shadow strength dial (`moonShadowStrength`), the
|
||||
// one resource the settings registry writes for this feature (`0` = the pre-feature night render),
|
||||
// plus the two halves of the hand-over law the SHADOW RIG has to agree with the light packer
|
||||
// about — which body the one directional light is aimed at, and how much it may cast.
|
||||
pub use global_light::{
|
||||
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
|
||||
};
|
||||
// MONKEY (merge 2026-09-17): upstream's world-light component, adopted as THE world light.
|
||||
pub use global_light::WorldPointLight;
|
||||
// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side
|
||||
@@ -204,6 +211,23 @@ impl WowLighting {
|
||||
self.celestial_dir
|
||||
}
|
||||
|
||||
/// MONKEY (moon shadows): the visible **white moon** direction (camera→moon, Bevy space) — the
|
||||
/// body the one shadow rig re-aims at after dark.
|
||||
///
|
||||
/// This is the renderer's REAL moon, not a stand-in for the sun: [`daynight::moon_direction`]
|
||||
/// is its own `DayNight` track pair off `WoW.exe`'s sky-bodies builder (elevation table
|
||||
/// `0xce8d24`, φ 35°↔100° — overhead at midnight, parked 10° under the horizon 04:00→22:00).
|
||||
/// Its AZIMUTH, however, is the constant 45° the celestial sun also uses (table `0xce8d0c`), so
|
||||
/// a moon shadow falls along the same compass line a midday sun shadow does, only from a lower
|
||||
/// and slower-moving elevation. That is the reference's own geometry, not an approximation
|
||||
/// here.
|
||||
///
|
||||
/// Exposed beside [`Self::celestial_dir`] and for the same reason: the shadow rig lives in
|
||||
/// `benilla-app` and must aim at the same body the light packer is weighting.
|
||||
pub fn moon_dir(&self) -> Vec3 {
|
||||
self.moon_dir_white
|
||||
}
|
||||
|
||||
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. These
|
||||
/// are the ENDPOINTS; the ramp between them is a 64-row byte-space accumulator that `liquid.wgsl`
|
||||
/// reproduces (`swatch_row`), not the plain lerp this doc used to describe — it stops one row short
|
||||
|
||||
@@ -1459,18 +1459,25 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// fade, via `benilla::shadow_hook`) — now with the same fades as terrain + models. INTERIOR
|
||||
// batches (`WORD_INTERIOR`) take the point-light (torch) shadow instead (#2): no sun reaches a
|
||||
// sealed room, but a promoted torch does. Both are no-ops when their light source is absent.
|
||||
// MONKEY (moon shadows): the NIGHT arm rides the SAME fetch and the SAME `WORD_INTERIOR` gate —
|
||||
// 1.0 (inert) on every interior batch, all day, and with the feature off. Interiors stay
|
||||
// untouched at both ends of the clock: a sealed room has no sky, so it has no moon either.
|
||||
var world_shadow = 1.0;
|
||||
var world_moon = 1.0;
|
||||
if ((in.word & WORD_INTERIOR) == 0u) {
|
||||
let view_z = (view.view_from_world * in.world_position).z;
|
||||
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
|
||||
world_shadow = shadow_hook::realtime_shadow(
|
||||
let terms = shadow_hook::realtime_shadow_terms(
|
||||
in.world_position,
|
||||
n_lit,
|
||||
view_z,
|
||||
cam_dist,
|
||||
wow_light.wmo_fog_params.z,
|
||||
wow_light.fog_params.z,
|
||||
shadow_hook::sun_shadow_w(wow_light.fog_params.z),
|
||||
shadow_hook::moon_shadow_w(wow_light.fog_params.z),
|
||||
);
|
||||
world_shadow = terms.x;
|
||||
world_moon = terms.y;
|
||||
} else {
|
||||
world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit);
|
||||
}
|
||||
@@ -1480,7 +1487,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
// `world_shadow < 0.999` arm keeps `worldShadows 0` (no shadow-mapped light) and a fully
|
||||
// sunlit fragment byte-identical: `ambient + (lit − ambient) × 1.0` is not guaranteed to
|
||||
// round back to `lit`.
|
||||
let lit_nl_shadowed = select(
|
||||
var lit_nl_shadowed = select(
|
||||
lit_nl,
|
||||
clamp(
|
||||
wow_light.light_ambient.rgb + (lit_nl - wow_light.light_ambient.rgb) * shadow_term,
|
||||
@@ -1489,6 +1496,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
),
|
||||
world_shadow < 0.999,
|
||||
);
|
||||
// MONKEY (moon shadows): shadow the unclamped exterior SKY, ambient AND directional.
|
||||
// Points join later, before the final saturation, so a hot torch stays hot beneath a canopy.
|
||||
// world_moon is exactly one indoors and when off; leave those arithmetic paths untouched.
|
||||
if (world_moon < 1.0) {
|
||||
lit_nl_shadowed = (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl)
|
||||
* world_moon;
|
||||
}
|
||||
|
||||
// The order-2 SH basis products over the fragment normal — shared by the exterior
|
||||
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
|
||||
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
|
||||
@@ -1992,7 +2007,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
wow_light.sh_c10_b.w,
|
||||
);
|
||||
let lit_doodad_plain = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
let lit_doodad = select(
|
||||
var lit_doodad = select(
|
||||
lit_doodad_plain,
|
||||
clamp(
|
||||
sun_ambient + (sun_lobe - sun_ambient) * shadow_term,
|
||||
@@ -2001,6 +2016,11 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
|
||||
),
|
||||
world_shadow < 0.999,
|
||||
);
|
||||
// MONKEY (moon shadows): retained ADT doodads and exterior MODD props select this SH
|
||||
// branch, not lit_nl_shadowed. Shadow the whole sky lobe before points/clamp here too.
|
||||
if (world_moon < 1.0) {
|
||||
lit_doodad = sun_lobe * world_moon;
|
||||
}
|
||||
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
|
||||
let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
|
||||
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
|
||||
|
||||
@@ -31,7 +31,10 @@ struct WowLight {
|
||||
light_sun: vec4<f32>,
|
||||
light_spec: vec4<f32>,
|
||||
fog_color: vec4<f32>, // rgb row-7 fog (gamma); w = enable (>0.5)
|
||||
fog_params: vec4<f32>, // x=start y=end z=linear-lighting A/B flag w=farclip wall
|
||||
// MONKEY (moon shadows): `.z` is the SIGNED directional-shadow weight (+sun / -moon, see
|
||||
// `global_light::pack_shadow_lane`) - unread here, but the old "linear-lighting A/B flag"
|
||||
// note was stale twice over and a lane this shader mirrors should not misname it.
|
||||
fog_params: vec4<f32>, // x=start y=end z=signed shadow weight w=farclip wall
|
||||
sh_c10_r: vec4<f32>,
|
||||
sh_c10_g: vec4<f32>,
|
||||
sh_c10_b: vec4<f32>,
|
||||
|
||||
Reference in New Issue
Block a user