2 Commits
Author SHA1 Message Date
threenebula23 40c08c16ae docs: procedural VHS, no shader or screen texture
CI / tests (push) Successful in 33s
2026-10-25 18:05:36 +03:00
threenebula23 d8e3c1bb49 fix(ui): make VHS procedural and non-blocking 2026-10-25 18:01:00 +03:00
11 changed files with 216 additions and 201 deletions

No files matched your search

+3 -2
View File
@@ -23,8 +23,9 @@ MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
- **VHS:** `shaders/vhs.gdshader` + `vhs_overlay.gd`, только главное меню,
чекбокс в настройках, HEADLESS без рендера.
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
+3 -3
View File
@@ -42,8 +42,9 @@
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
громкость; генератор `audio/generate_ambient.py`.
- VHS-эффект только в главном меню (настройки → «ЭКРАН»); игровой UI остаётся
чистым, без CRT на весь интерфейс.
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
Игровой UI остаётся чистым, без CRT на весь интерфейс.
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
Ограничения:
@@ -143,7 +144,6 @@ peak-hour/
├─ data/ # balance.json, strings_ru.json, names_ru.json
├─ art/fonts/ # Handjet, Press Start 2P + OFL
├─ audio/ # sfx/, music/, amb/, generate_*py
├─ shaders/ # vhs.gdshader (только главное меню)
├─ tests/ # headless-наборы
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
└─ build/ # артефакты экспорта (gitignored)
+7 -6
View File
@@ -99,7 +99,7 @@
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
## D14 — фон
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
**Решение:** фон — статичный градиент + слабый шум 320×180,
без полноэкранного fbm; CRT-эффект убрать.
**GDD:** §21.
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
@@ -230,11 +230,12 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
## D27 — VHS главного меню
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
фильтром, модалки — нет; это не CRT на весь UI.
**Решение:** VHS-слой только на главном меню: процедурный
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
**GDD:** §21, §25.
## D28 — ambient machine room
+9 -6
View File
@@ -188,9 +188,11 @@ SLA ≤ 0 (смена не отработана), экран итогов. Пе
декоративных подписей. «Спокойный фон» — плоская `PAPER` без dither
(D18, D24, D26).
- Маскот **БАЙТ** ведёт короткий туториал первого часа (D24).
- VHS-эффект **только на главном меню**: шейдер `shaders/vhs.gdshader` и
`scripts/ui/vhs_overlay.gd`; скан-линии 2 физ. px, слабая хроматика, зерно,
виньетка, полоса. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
- VHS-эффект **только на главном меню**: процедурный слой
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста; скан-линии 2 физ. px, слабая хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
Игровой UI без CRT; это не полноэкранный CRT (D14, D27).
- Фон: тёплый бумажный дизеринг и светлые панели, без полноэкранного fbm;
CRT убрать (D14, D18).
@@ -241,9 +243,10 @@ RU-тексты, save/load и мета-открытия, HTML5-сборка ра
- **D26 — фиксы аудита:** calm-фон без серого `#C0C0C0`, Esc на итогах →
меню без soft-lock, реальное уничтожение папки, прогрессивная мета,
час 1 без атак, честный резерв `minigame_advanced`.
- **D27 — VHS главного меню:** `vhs.gdshader` + `vhs_overlay.gd`, screen
texture 1920, скан-линии 2 физ. px, хроматика/зерно/виньетка; только меню,
чекбокс в настройках, HEADLESS без рендера. Не CRT на весь UI.
- **D27 — VHS главного меню:** процедурный `vhs_overlay.gd`
(`extends RefCounted`) в `MainMenu._draw`; скан-линии, хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий; только меню, чекбокс, HEADLESS без рендера. Не CRT на весь UI.
- **D28 — ambient machine room:** 4 WAV в `audio/amb`, генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость;
тест `test_ambient` — 74/0.
+3 -3
View File
@@ -33,9 +33,9 @@
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
В главном меню — лёгкий VHS-эффект, который отключается в настройках; в игре
UI остаётся чистым. Фон — ambient машинного зала, включается после первого
жеста и регулируется вместе с музыкой.
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads:
+26 -26
View File
@@ -39,7 +39,7 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
@@ -68,39 +68,39 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
## VHS-эффект главного меню
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
читает готовый кадр через `hint_screen_texture`.
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
Ключевое — **native-res**: эффект считается в физических пикселях окна
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
Порядок отрисовки:
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
буквы не двоятся;
- полоса меняет только яркость и не двигает изображение, поэтому текст
под ней не «плывёт».
1. бумага;
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
3. карточка, правая панель и декоративные фигуры;
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
5. текст и кнопки.
Параметры (`VhsOverlay` / шейдер):
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
и остаётся резким.
Параметры (`VhsOverlay`):
| Параметр | Значение | Смысл |
| --- | --- | --- |
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
| `vignette` | 0.11 | затемнение к краям кадра |
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
| виньетка | 0.13 | затемнение к краям кадра |
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
**Как отключить:**
+30 -8
View File
@@ -31,6 +31,9 @@ var _btn_about: Button90
static var _card_style: StyleBoxFlat = null
## Накопитель для перерисовки VHS на 12 Гц.
var _vhs_clock := 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_STOP
@@ -61,6 +64,18 @@ func is_vhs_enabled() -> bool:
return vhs != null and vhs.enabled
## Медленная анимация: полоса и зерно. Перерисовываем меню на 12 Гц —
## чаще для такого эффекта не нужно, а текст остаётся тем же.
func _process(delta: float) -> void:
if vhs == null or not vhs.enabled or not vhs.is_available() or not visible:
return
vhs.advance(delta)
_vhs_clock += delta
if _vhs_clock >= 1.0 / 12.0:
_vhs_clock = 0.0
queue_redraw()
func _build() -> void:
_btn_continue = _make_button("Продолжить")
_btn_continue.disabled = not has_save
@@ -79,14 +94,11 @@ func _build() -> void:
_btn_about.pressed.connect(func() -> void: about.emit())
## VHS-слой — полноэкранный ColorRect поверх меню. Он фильтрует карточку,
## текст и кнопки, но остаётся внутри MainMenu; модалки — отдельные соседи
## в modal_root, они рисуются поверх и не фильтруются.
## VHS-слой — процедурный помощник (RefCounted, без Node/Control).
## Меню рисует его само в _draw(), поэтому ничто не перекрывает меню
## и не перехватывает мышь. Модалки — отдельные соседи в modal_root.
func _build_vhs() -> void:
vhs = VHS_OVERLAY.new()
vhs.name = "VhsOverlay"
add_child(vhs)
vhs.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
func _tr(key: String, fallback: String) -> String:
@@ -138,12 +150,22 @@ func _draw() -> void:
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g, Theme90.ACCENT_LIGHT.b, 0.10))
draw_circle(Vector2(full.size.x * 0.06, full.size.y * 0.96), 300.0,
Color(0.96, 0.80, 0.72, 0.09))
# 2. Карточка, панель, декоративные фигуры и текст. VHS — отдельный
# full-rect слой-ребёнок, он фильтрует весь кадр меню в native-res.
# 2. VHS на бумаге.
if vhs != null:
vhs.draw_layer(self, full)
var w := _window()
var panel := _panel_rect(w)
# 3. Поверхности: карточка, панель, декоративные фигуры.
_draw_card(w)
_draw_actions_surface(w)
_draw_hero_shapes(w)
# 4. Мягкий VHS-проход по карточке и панели — ДО текста, поэтому буквы
# и кнопки остаются резкими.
if vhs != null:
vhs.draw_layer(self, w, true)
vhs.draw_layer(self, panel, true)
# 5. Текст поверх эффекта.
_draw_hero_text(w)
_draw_actions_text(w)
+132 -57
View File
@@ -1,79 +1,154 @@
class_name VhsOverlay
extends ColorRect
## Нативный VHS/CRT-слой главного меню.
extends RefCounted
## Процедурный VHS-слой главного меню. НЕ создаёт Node/Control и не читает
## screen texture.
##
## Полноэкранный ColorRect с canvas_item-шейдером: читает готовый кадр через
## hint_screen_texture и добавляет скан-линии (2 физических px), очень слабое
## зерно, хроматику ≤0.0003 UV, виньетку и редкую полосу. Считается в
## разрешении фактического окна (1920×1080), а не в логических 1280×720,
## поэтому линии ровные, без блоков и banding; текст остаётся резким.
## Меню рисует его прямо в MainMenu._draw() в два прохода:
## бумага → VHS фона → карточка/панель/фигуры → VHS по ним → текст.
## Поэтому слой не перекрывает меню, не перехватывает мышь и не может
## затереть кадр фоном подготовки. Кнопки — дети MainMenu, они поверх.
##
## Слой — ребёнок MainMenu и последний в дереве: он фильтрует меню (включая
## кнопки), но соседние модалки в modal_root рисуются поверх и не фильтруются.
## Состояние и параметры здесь; рисование вызывает меню:
## vhs.draw_layer(self, full_rect)
## vhs.advance(delta) + queue_redraw() при анимации
##
## В HEADLESS материал не создаётся и слой скрыт — тесты не тормозят.
## В HEADLESS текстуры не создаются и draw_layer() ничего не рисует.
const SHADER_PATH := "res://shaders/vhs.gdshader"
## Скан-линии: шаг 2 логических px (при 1920×1080 — 3 физ. px), 1 px затемнения.
const SCANLINE_STEP := 2.0
const SCANLINE_STRENGTH := 0.07
const GRAIN_SIZE := 128
const VIGNETTE_W := 320
const VIGNETTE_H := 180
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false)
## или напрямую overlay.set_enabled(false).
var enabled := true:
set(value):
enabled = value
_refresh()
const DARK := Color(0.035, 0.045, 0.05)
const LIGHT := Color(0.99, 0.99, 0.96)
## Общая сила слоя, 0.0..0.55. Выше 0.55 текст начинает терять контраст.
var intensity := 0.40:
set(value):
intensity = clampf(value, 0.0, 0.55)
if _mat != null:
_mat.set_shader_parameter("intensity", intensity)
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false).
var enabled := true
## Общая сила слоя, 0.0..0.55.
var intensity := 0.42
## Внутреннее время анимации (полоса и зерно).
var time := 0.0
var _mat: ShaderMaterial = null
var _grain: ImageTexture = null
var _vignette: ImageTexture = null
var _band: ImageTexture = null
var _ready_ok := false
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_IGNORE
focus_mode = Control.FOCUS_NONE
color = Color(1, 1, 1, 1)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_material()
func _init() -> void:
# В headless не тратим время на генерацию текстур и анимацию.
_ready_ok = DisplayServer.get_name() != "headless"
if not _ready_ok:
return
_build_grain()
_build_vignette()
_build_band()
## Публичный переключатель: true — эффект виден, false — слой полностью скрыт.
func set_enabled(value: bool) -> void:
enabled = value
func is_available() -> bool:
return _mat != null
return _ready_ok
func _build_material() -> void:
# В headless экранный шейдер не нужен и только замедляет тесты.
if DisplayServer.get_name() == "headless":
visible = false
set_process(false)
## Продвинуть время анимации. Вызывается меню из _process.
func advance(delta: float) -> void:
if enabled and _ready_ok:
time += delta
## Нарисовать VHS в rect. Canvas — обычно сам MainMenu.
##
## over_content=false — проход по бумажному фону: скан-линии, зерно,
## виньетка и полоса. over_content=true — мягкий проход по карточке и панели
## ДО отрисовки текста: чуть слабее, без виньетки, но с той же полосой.
func draw_layer(canvas: CanvasItem, rect: Rect2, over_content: bool = false) -> void:
if not enabled or not _ready_ok:
return
var shader := load(SHADER_PATH) as Shader
if shader == null:
push_warning("VHS: не удалось загрузить %s" % SHADER_PATH)
visible = false
return
_mat = ShaderMaterial.new()
_mat.shader = shader
_mat.set_shader_parameter("intensity", intensity)
material = _mat
_refresh()
var k := 0.72 if over_content else 1.0
# 1. Скан-линии: 1 px затемнения каждые 2 px.
var scan := Color(DARK.r, DARK.g, DARK.b, SCANLINE_STRENGTH * intensity * k)
var y := rect.position.y
while y < rect.end.y:
canvas.draw_rect(Rect2(rect.position.x, y, rect.size.x, 1.0), scan)
y += SCANLINE_STEP
# 2. Зерно: тайл 128×128, обновляется 2 раза в секунду.
var step := int(floor(time * 2.0))
var off := Vector2(float((step * 37) % GRAIN_SIZE), float((step * 53) % GRAIN_SIZE))
canvas.draw_texture_rect(_grain,
Rect2(rect.position - off, rect.size + off), true,
Color(1, 1, 1, intensity * k))
# 3. Виньетка — только у краёв кадра, не на карточке.
if not over_content:
canvas.draw_texture_rect(_vignette, rect, false, Color(1, 1, 1, intensity))
# 4. Редкая полоса прокрутки: медленно идёт сверху вниз с лёгким дрожанием.
# Позиция считается по логической высоте кадра (720), поэтому полоса
# совпадает на бумаге и на карточке; rect только обрезает её.
var band_h := 96.0
var span := 720.0 + band_h * 2.0
var band_top := fposmod(time * 0.055, 1.0) * span - band_h
var top := maxf(band_top, rect.position.y)
var bottom := minf(band_top + band_h, rect.end.y)
if bottom > top:
var v0 := (top - band_top) / band_h
var v1 := (bottom - band_top) / band_h
var src := Rect2(0.0, v0 * _band.get_height(), _band.get_width(),
(v1 - v0) * _band.get_height())
var jitter := sin(time * 0.7) * 4.0
canvas.draw_texture_rect_region(_band,
Rect2(rect.position.x + jitter, top, rect.size.x, bottom - top),
src, Color(1, 1, 1, intensity * k))
func _refresh() -> void:
# Сеттер может сработать до _ready — тогда состоянием займётся _ready.
if not is_node_ready():
return
var on: bool = enabled and _mat != null
visible = on
set_process(on)
# TIME в canvas_item шейдере обновляется движком автоматически, пока
# слой видим; ручных uniform-обновлений не нужно.
func _build_grain() -> void:
var img := Image.create(GRAIN_SIZE, GRAIN_SIZE, false, Image.FORMAT_RGBA8)
for yy in GRAIN_SIZE:
for xx in GRAIN_SIZE:
var n := float((xx * 73 + yy * 151 + 31) % 97) / 97.0
var c := DARK if n < 0.5 else LIGHT
var a := absf(n - 0.5) * 2.0 * 0.05
img.set_pixel(xx, yy, Color(c.r, c.g, c.b, a))
_grain = ImageTexture.create_from_image(img)
func _build_vignette() -> void:
var img := Image.create(VIGNETTE_W, VIGNETTE_H, false, Image.FORMAT_RGBA8)
for yy in VIGNETTE_H:
for xx in VIGNETTE_W:
var q := Vector2(float(xx) / VIGNETTE_W - 0.5,
float(yy) / VIGNETTE_H - 0.5)
q.x *= 16.0 / 9.0
var v := smoothstep(0.34, 0.94, q.length())
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b, v * 0.13))
_vignette = ImageTexture.create_from_image(img)
func _build_band() -> void:
const BW := 128
const BH := 32
var img := Image.create(BW, BH, false, Image.FORMAT_RGBA8)
for yy in BH:
var cy := absf(float(yy) / (BH - 1) - 0.5) * 2.0
var band_a := (1.0 - cy * cy) * 0.12
for xx in BW:
# слабые вертикальные штрихи — при дрожании полоса «плывёт»
var streak := 0.85 + 0.15 * float((xx * 29) % 17) / 17.0
if yy == 0:
# светлая ведущая кромка — «трекинг» кассеты
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.45 * streak))
elif yy == BH - 1:
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.28 * streak))
else:
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b,
band_a * streak))
_band = ImageTexture.create_from_image(img)
-82
View File
@@ -1,82 +0,0 @@
shader_type canvas_item;
render_mode unshaded;
// Нативный VHS/CRT-слой главного меню.
//
// Считается в ФИЗИЧЕСКИХ пикселях окна: скан-линии идут по FRAGCOORD,
// поэтому при 1920×1080 шаг ровно 2 px, без блоков и banding. Экранная
// текстура читается с линейной фильтрацией 1:1 — буквы не размываются.
// Хроматическая аберрация минимальна (0.0003 UV ≈ 0.6 px на краю кадра)
// и не сдвигает зелёный канал, поэтому форма букв остаётся резкой.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
uniform float intensity : hint_range(0.0, 0.55) = 0.40;
// Скан-линии: 2 физических px, затемнение до 6%.
uniform float scanline_strength : hint_range(0.0, 0.12) = 0.06;
uniform float scanline_step : hint_range(2.0, 3.0) = 2.0;
// Хроматика: очень мягкая, чтобы не двоить текст.
uniform float aberration : hint_range(0.0, 0.0006) = 0.0003;
// Зерно: ±0.025, обновление 2 Гц.
uniform float grain : hint_range(0.0, 0.04) = 0.025;
// Виньетка по краям кадра.
uniform float vignette : hint_range(0.0, 0.18) = 0.11;
// Медленная полоса прокрутки: только яркость, без сдвига картинки.
uniform float band_strength : hint_range(0.0, 0.05) = 0.04;
uniform float band_speed : hint_range(0.01, 0.3) = 0.055;
const vec3 BAND_TINT = vec3(0.85, 1.0, 1.0);
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
void fragment() {
vec2 res = 1.0 / max(SCREEN_PIXEL_SIZE, vec2(1e-6));
vec2 uv = SCREEN_UV;
// Хроматика: R/B расходятся к краям, G сэмплится ровно по UV.
float edge = 0.35 + abs(uv.x - 0.5) * 1.3;
vec2 shift = vec2(aberration * edge, 0.0);
vec3 col;
col.r = texture(screen_texture, uv + shift).r;
col.g = texture(screen_texture, uv).g;
col.b = texture(screen_texture, uv - shift).b;
// Скан-линии по физическим строкам. FRAGCOORD — центр пикселя (n+0.5),
// поэтому фазу берём от floor(y)+1, иначе cos((n+0.5)·PI) == 0.
float row = floor(FRAGCOORD.y);
float line = 0.5 + 0.5 * cos((row + 1.0) * (6.2831853 / scanline_step));
col *= 1.0 - scanline_strength * line;
// Зерно: пиксельный шум, обновляется 2 раза в секунду.
float gt = floor(TIME * 2.0);
float n = hash21(floor(FRAGCOORD.xy) + vec2(gt * 31.7, gt * 17.3));
col += (n - 0.5) * grain;
// Виньетка с поправкой на соотношение сторон.
vec2 q = uv - 0.5;
q.x *= res.x / max(res.y, 1.0);
col *= 1.0 - vignette * smoothstep(0.35, 0.95, length(q));
// Полоса: медленно идёт сверху вниз. Меняет только яркость и не двигает
// картинку, поэтому текст под ней не «плывёт».
float band_y = fract(TIME * band_speed + 0.15);
float d1 = abs(fract(uv.y - band_y + 0.5) - 0.5);
float d2 = abs(fract(uv.y - band_y - 0.5 + 0.5) - 0.5);
float band = exp(-pow(d1 / 0.045, 2.0))
+ 0.45 * exp(-pow(d2 / 0.035, 2.0));
col += band * band_strength * BAND_TINT;
vec3 base = texture(screen_texture, uv).rgb;
COLOR = vec4(mix(base, col, intensity), 1.0);
}
-1
View File
@@ -1 +0,0 @@
uid://kqjc8uchsx0s
+3 -7
View File
@@ -254,13 +254,9 @@ func _test_vhs(main) -> void:
_ok(overlay != null, "VHS-слой создан")
if overlay == null:
return
_ok(overlay.get_parent() == main.main_menu, "VHS-слой — ребёнок главного меню")
_ok(main.main_menu.get_child(main.main_menu.get_child_count() - 1) == overlay,
"VHS-слой поверх карточки и кнопок меню")
_eq(overlay.mouse_filter, Control.MOUSE_FILTER_IGNORE, "VHS не ловит мышь")
_eq(overlay.process_mode, Node.PROCESS_MODE_ALWAYS, "VHS работает при паузе")
_ok(not overlay.is_available(), "в headless VHS-материал не создаётся")
_ok(not overlay.visible, "в headless VHS-слой не рендерится")
_ok(overlay is RefCounted and not (overlay is Node),
"VHS — RefCounted, не создаёт Node/Control")
_ok(not overlay.is_available(), "в headless VHS-текстуры не создаются")
_ok(main.main_menu.is_vhs_enabled(), "VHS включён по умолчанию")
main.main_menu.set_vhs_enabled(true)