From 99f35efe50748a539283d3615d3fccd807d95666 Mon Sep 17 00:00:00 2001 From: LTDigor Date: Tue, 29 Sep 2026 01:08:27 +0300 Subject: [PATCH] fix: render a smooth shader spotlight --- .github/workflows/release.yml | 4 +- README.md | 14 +- build.gradle | 8 + scripts/requirements-test.txt | 2 + scripts/shader_beam_test.py | 33 +++ scripts/test_shader_beam.py | 163 ++++++++++ .../flashlightfe/FlashlightConfig.java | 2 +- .../client/ComplementaryBeamPatch.java | 54 ++++ .../client/OptionalDynamicLights.java | 31 +- .../flashlightfe/client/ShaderBeamBridge.java | 126 ++++++++ .../client/ShaderBeamSession.java | 30 ++ .../flashlightfe/client/ShaderBeamState.java | 9 + .../mixin/BestFlashlightMixinPlugin.java | 4 + .../flashlightfe/mixin/IrisPipelineMixin.java | 24 ++ .../mixin/IrisProgramSourceMixin.java | 23 ++ .../flashlightfe/mixin/IrisUniformsMixin.java | 19 ++ .../bestflashlight/shaders/spotlight.glsl | 29 ++ src/main/resources/bestflashlight.mixins.json | 1 + .../flashlightfe/client/ShaderBeamSmoke.java | 278 ++++++++++++++++++ .../OptionalShaderClassloadingTest.java | 16 + .../client/ComplementaryBeamPatchTest.java | 38 +++ .../client/ShaderBeamSessionTest.java | 44 +++ 22 files changed, 940 insertions(+), 12 deletions(-) create mode 100644 scripts/requirements-test.txt create mode 100644 scripts/shader_beam_test.py create mode 100644 scripts/test_shader_beam.py create mode 100644 src/main/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatch.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamBridge.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamSession.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamState.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/mixin/IrisPipelineMixin.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/mixin/IrisProgramSourceMixin.java create mode 100644 src/main/java/com/ltdigor/flashlightfe/mixin/IrisUniformsMixin.java create mode 100644 src/main/resources/assets/bestflashlight/shaders/spotlight.glsl create mode 100644 src/smoke/java/com/ltdigor/flashlightfe/client/ShaderBeamSmoke.java create mode 100644 src/test/java/com/ltdigor/flashlightfe/OptionalShaderClassloadingTest.java create mode 100644 src/test/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatchTest.java create mode 100644 src/test/java/com/ltdigor/flashlightfe/client/ShaderBeamSessionTest.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d201c86..5fa6784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,9 @@ jobs: with: distribution: temurin java-version: '21' - - name: Test release safeguards (including private/disabled builds) + - name: Install image acceptance test dependencies + run: python3 -m pip install -r scripts/requirements-test.txt + - name: Test release safeguards and spotlight image analyzer run: python3 -m unittest discover -s scripts -p '*_test.py' - name: Build and compile smoke tests run: ./gradlew --no-daemon build smokeClasses diff --git a/README.md b/README.md index 94d9a01..36871a5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Server settings live in `config/bestflashlight-server.toml`. A world's `serverco | `energyPerTick` | 0.025 | FE per emitting tick; 0 disables consumption | | `worksUnderwater` | true | Allow submerged emitters | | `beamRange` | 12.0 | 1–32 blocks | -| `coneAngleDegrees` | 35.0 | 1–90 degrees, full cone angle | +| `coneAngleDegrees` | 50.0 | 1–90 degrees, full cone angle | | `beamBrightness` | 15 | 1–15, maximum light level | | `beamSoftness` | 0.35 | 0–1, radial fraction occupied by the fading edge | @@ -47,11 +47,19 @@ Default charge lasts about 5 hours 33 minutes at 20 TPS. The beam follows the vi Without LambDynamicLights, temporary server light blocks illuminate the world. Their circular placement and intensity follow the cone, but Minecraft block light remains discrete and spreads beyond its sources. Source and flowing water are preserved; overlapping beams combine by maximum brightness and clear when no longer needed. -With a compatible, enabled LambDynamicLights, surfaces receive a soft circular light profile bounded by the cone. LDL still samples Minecraft's block grid, so narrow spots retain visible block-shaped shading; this is not a per-pixel shader spotlight. There is no glowing volumetric fog effect. The server stops its temporary lights only after all clients in the dimension acknowledge readiness. Mixed clients, disabled LDL, and incompatible APIs keep server fallback, preventing double illumination. Tested real LDL fixture: 4.8.11 for NeoForge 1.21.1. +With **LambDynamicLights 4.8.11 + Iris 1.8.14-beta.1 + Complementary Reimagined r5.9.3**, the local first-person flashlight uses a per-pixel surface spotlight. A perpendicular wall receives a smooth circle; tilted surfaces receive the corresponding ellipse. The beam follows the camera up and down, and its diameter on a surface grows with distance. Existing range, cone angle, brightness and softness settings control this light. Both hands and the headlamp use the same source priority and battery rules. + +The optional adapter augments the loaded Complementary programs in memory; it does not modify the shader-pack ZIP. It activates only after the supported programs and uniforms are ready, replacing the local player's LDL cone while preserving other LDL sources. The supported pack is named `ComplementaryReimagined_r5.9.3.zip` (or the equivalent unpacked directory). Unsupported lighting structures retain LDL lighting and report an incompatibility. Other shader packs and versions are not covered by this adapter. + +Without that shader combination, LDL supplies the existing block-grid approximation. Third-person views and other players also retain that path. There is no glowing volumetric fog effect. The server stops its temporary lights only after all clients in the dimension acknowledge readiness. Mixed clients, disabled LDL, and incompatible APIs keep server fallback; the shader spotlight stays inactive in those sessions. ## Build and test -Use Java 21 and `./gradlew build smokeClasses runGameTestServer`. Offline asset/recipe checks: `python3 -m unittest discover -s scripts -p '*_test.py'`. +Use Java 21 and `./gradlew build smokeClasses runGameTestServer`. Install test-only Python dependencies with `python3 -m pip install -r scripts/requirements-test.txt`, then run offline checks with `python3 -m unittest discover -s scripts -p '*_test.py'`. + +Real shader acceptance: `python3 scripts/test_shader_beam.py --minecraft-dir `. It copies cached LDL/Iris/Sodium JARs, Complementary r5.9.3 and its options into ignored `run-shader-smoke/`, explicitly enables shaders there, and creates a disposable world. The play instance is read-only. No mods or shader packs are downloaded. Add `--baseline` to exercise the previous LDL rendering with the adapter disabled, or `--cases wall-35-4.0,ceiling` for a focused run. + +The fixture verifies an active Iris shader pipeline, captures off/on/level-15 reference frames, and checks circular contours at 25/50/75% intensity (maximum two-pixel radial error), alignment, brightness and distance scaling. It also exercises ceiling/floor aiming, both hands, headlamp, empty charge, shader reload/toggle, camera handoff, obstacles and a fresh-world transition. Square boundaries and isolated bright blocks fail the target shader mode. Results and screenshots remain under `run-shader-smoke/fixed/` or `baseline/`; `--analyze-only` repeats image analysis. A failed shader launch is a test failure, even if Gradle itself exits successfully. The real client matrix uses `./gradlew runCompatibilityClient -PcompatCurios=absent|empty|head -PcompatLdl=false|true`. For LDL runs, also pass `-PbeamSmokeModDir=`. Run each value separately, or use `python3 scripts/test_compatibility.py --ldl-dir `. diff --git a/build.gradle b/build.gradle index 36a416d..da1198a 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,14 @@ neoForge { systemProperty 'bestflashlight.beamSmoke.stages', providers.gradleProperty('beamSmokeStages').getOrElse('') programArgument '--username'; programArgument 'BeamSmoke' } + shaderBeamSmoke { + client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke] + gameDirectory = file('run-shader-smoke') + systemProperty 'bestflashlight.shaderSmoke', 'true' + systemProperty 'bestflashlight.shaderBeam.disabled', providers.gradleProperty('shaderBeamBaseline').getOrElse('false') + systemProperty 'bestflashlight.shaderSmoke.cases', providers.gradleProperty('shaderBeamCases').getOrElse('') + programArgument '--username'; programArgument 'ShaderBeamSmoke' + } clientReloadSmoke { client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-client-smoke'); systemProperty 'bestflashlight.smoke', 'true'; systemProperty 'bestflashlight.smoke.reload', 'true'; programArgument '--username'; programArgument 'FlashlightSmoke' } multiplayerServer { server(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-multiplayer-server'); systemProperty 'bestflashlight.multiplayer.server', 'true'; programArgument '--nogui' } multiplayerA { client(); sourceSet = sourceSets.smoke; loadedMods = [mods.bestflashlightSmoke]; gameDirectory = file('run-client-a'); systemProperty 'bestflashlight.multiplayer.client', 'true'; programArgument '--username'; programArgument 'FlashlightA' } diff --git a/scripts/requirements-test.txt b/scripts/requirements-test.txt new file mode 100644 index 0000000..9c38005 --- /dev/null +++ b/scripts/requirements-test.txt @@ -0,0 +1,2 @@ +numpy==2.4.4 +Pillow==12.2.0 diff --git a/scripts/shader_beam_test.py b/scripts/shader_beam_test.py new file mode 100644 index 0000000..94d4f93 --- /dev/null +++ b/scripts/shader_beam_test.py @@ -0,0 +1,33 @@ +import unittest +import numpy as np +from test_shader_beam import measure_spot + + +class SpotAcceptanceTest(unittest.TestCase): + def image(self, square=False, shift=0): + y, x = np.mgrid[:256, :256] + x, y = x - 127.5 - shift, y - 127.5 + r = np.maximum(abs(x), abs(y)) if square else np.hypot(x, y) + intensity = np.clip(1 - (r / 60) ** 2, 0, 1) + return np.repeat(intensity[:, :, None], 3, axis=2) + + def test_round_spot_passes(self): + result = measure_spot(np.zeros((256, 256, 3)), self.image(), np.ones((256, 256, 3))) + self.assertLess(result['max_radial_error_px'], 2) + self.assertLess(result['center_error_px'], 1) + + def test_square_spot_fails_even_when_its_width_equals_its_height(self): + result = measure_spot(np.zeros((256, 256, 3)), self.image(square=True), np.ones((256, 256, 3))) + self.assertGreater(result['max_radial_error_px'], 2) + + def test_off_center_circle_is_rejected(self): + result = measure_spot(np.zeros((256, 256, 3)), self.image(shift=8), np.ones((256, 256, 3))) + self.assertGreater(result['center_error_px'], 2) + + def test_dark_frame_does_not_pass_as_a_circle(self): + with self.assertRaises(ValueError): + measure_spot(np.zeros((256, 256, 3)), np.zeros((256, 256, 3)), np.ones((256, 256, 3))) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test_shader_beam.py b/scripts/test_shader_beam.py new file mode 100644 index 0000000..392ef87 --- /dev/null +++ b/scripts/test_shader_beam.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Offline real-Iris beam acceptance. Copies cached inputs into an isolated run directory.""" +import argparse +import csv +import hashlib +import json +import math +from pathlib import Path +import shutil +import subprocess +import sys + +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +RUN = ROOT / 'run-shader-smoke' +PACK = 'ComplementaryReimagined_r5.9.3.zip' + + +def measure_spot(off, on, reference): + weights = np.array([.2126, .7152, .0722]) + delta = np.maximum(0, (on - off) @ weights) + ref = np.maximum(0, (reference - off) @ weights) + h, w = delta.shape + cy, cx = (h - 1) / 2, (w - 1) / 2 + y, x = np.mgrid[:h, :w] + center = np.hypot(x - cx, y - cy) <= 3 + peak = float(np.median(delta[center])) + if peak < .02: + raise ValueError('No visible light at the crosshair') + # Divide by the same material's level-15 reference to remove texture albedo. + profile = delta / np.maximum(ref, .02) + relative_peak = float(np.median(profile[center])) + contours = [] + for fraction in (.25, .5, .75): + mask = profile >= relative_peak * fraction + inside = mask.copy() + inside[1:-1, 1:-1] &= mask[:-2, 1:-1] & mask[2:, 1:-1] & mask[1:-1, :-2] & mask[1:-1, 2:] + ys, xs = np.where(mask & ~inside) + if len(xs) < 16 or mask[0].any() or mask[-1].any() or mask[:, 0].any() or mask[:, -1].any(): + raise ValueError('Missing, clipped or unbounded spotlight contour') + mx, my = (xs.min() + xs.max()) / 2, (ys.min() + ys.max()) / 2 + radii = np.hypot(xs - mx, ys - my) + radius = float(np.median(radii)) + contours.append({'fraction': fraction, 'radius_px': radius, + 'radial_error_px': float(np.max(np.abs(radii - radius))), + 'center_error_px': float(np.hypot(mx - cx, my - cy))}) + return {'contours': contours, 'max_radial_error_px': max(c['radial_error_px'] for c in contours), + 'center_error_px': max(c['center_error_px'] for c in contours), + 'center_relative_brightness': relative_peak, + 'saturated_fraction': float(np.mean(np.max(on[center], axis=1) >= .995))} + + +def analyze(output): + rows = list(csv.DictReader((output / 'cases.csv').open())) + cases = {row['case']: row for row in rows} + results = {} + failures = [] + for name, row in cases.items(): + try: + frames = [np.asarray(Image.open(output / 'screenshots' / f'{name}-{phase}.png').convert('RGB'), dtype=float) / 255 + for phase in ('off', 'on', 'reference')] + if name == 'discharged': + difference = float(np.max(np.abs(frames[1] - frames[0]))) + if difference > .03: failures.append(f'{name}: empty lamp changes rendered light') + results[name] = {'max_difference': difference} + continue + if name in ('third-person', 'toggle-shaders'): + # The fixture asserts actual LDL registration; its block-grid shape is not a shader-circle test. + results[name] = {'lifecycle_verified': True} + continue + if name == 'obstacles': + h, w, _ = frames[1].shape + center = float(np.mean(frames[1][h//2-3:h//2+3, w//2-3:w//2+3])) + # The opaque black block covers the aiming ray. A screen overlay would wash it white. + if center > .5: failures.append(f'{name}: opaque dark material washed out by light overlay') + results[name] = {'occluder_luminance': center} + continue + metrics = measure_spot(*frames) + focal = frames[0].shape[0] / (2 * math.tan(math.radians(35))) + h, w, _ = frames[0].shape + y, x = np.mgrid[:h, :w] + outside = np.hypot(x - (w-1)/2, y - (h-1)/2) > focal * math.tan(math.radians(float(row['angle']) / 2)) + 3 + spill = float(np.quantile(np.maximum(0, frames[1] - frames[0])[outside], .999)) + metrics['outside_spill'] = spill + if spill > .02: failures.append(f'{name}: light outside the cone') + metrics['diameter_world_50'] = 2 * metrics['contours'][1]['radius_px'] * float(row['distance']) / focal + results[name] = metrics + if metrics['max_radial_error_px'] > 2: failures.append(f'{name}: contour error > 2 px') + if metrics['center_error_px'] > 2: failures.append(f'{name}: centre error > 2 px') + if metrics['center_relative_brightness'] < .9: failures.append(f'{name}: brightness < 90% of level-15 reference') + if metrics['saturated_fraction'] > .01: failures.append(f'{name}: clipped centre texture') + except (ValueError, FileNotFoundError) as error: + failures.append(f'{name}: {error}') + for angle in (15, 35, 50): + group = [(float(cases[n]['distance']), m['diameter_world_50']) for n, m in results.items() if n.startswith(f'wall-{angle}-')] + if len(group) > 1: + group.sort() + slopes = [diameter / distance for distance, diameter in group] + if max(slopes) / min(slopes) > 1.05: failures.append(f'angle {angle}: diameter does not scale with distance') + if any(a[1] >= b[1] for a, b in zip(group, group[1:])): failures.append(f'angle {angle}: diameter plateau') + if not cases: failures.append('No captured test cases') + report = {'passed': not failures, 'cases': results, 'failures': failures} + (output / 'metrics.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'passed': report['passed'], 'cases': len(results), 'failures': failures}, indent=2)) + return not failures + + +def prepare(source): + if source.resolve() == RUN.resolve(): raise ValueError('Source must be the read-only play instance') + for name in ('mods', 'config', 'shaderpacks'): (RUN / name).mkdir(parents=True, exist_ok=True) + inputs = [] + for pattern in ('iris-neoforge-*.jar', 'sodium-neoforge-*.jar', 'lambdynamiclights-*.jar'): + candidates = list((source / 'mods').glob(pattern)) + if len(candidates) != 1: raise ValueError(f'Expected one cached {pattern}, found {len(candidates)}') + for old in (RUN / 'mods').glob(pattern): old.unlink() + path = candidates[0] + shutil.copy2(path, RUN / 'mods' / path.name) + inputs.append(path) + shader = source / 'shaderpacks' / PACK + shutil.copy2(shader, RUN / 'shaderpacks' / PACK) + inputs.append(shader) + options = source / 'shaderpacks' / (PACK + '.txt') + if options.exists(): shutil.copy2(options, RUN / 'shaderpacks' / options.name); inputs.append(options) + for config in ('iris.properties', 'lambdynlights.toml'): + shutil.copy2(source / 'config' / config, RUN / 'config' / config) + inputs.append(source / 'config' / config) + # The play instance may be toggled while tests run. Test the requested combination explicitly. + iris = RUN / 'config' / 'iris.properties' + lines = [line for line in iris.read_text().splitlines() if not line.startswith(('enableShaders=', 'shaderPack='))] + iris.write_text('\n'.join(lines + ['enableShaders=true', f'shaderPack={PACK}']) + '\n') + (RUN / 'inputs.json').write_text(json.dumps({str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in inputs}, indent=2)) + muted = [f'soundCategory_{x}:0' for x in ('master','music','record','weather','block','hostile','neutral','player','ambient','voice')] + (RUN / 'options.txt').write_text('\n'.join(['onboardAccessibility:false', 'pauseOnLostFocus:false', 'renderDistance:4', + 'simulationDistance:5', 'maxFps:60', 'overrideWidth:960', 'overrideHeight:640', 'gamma:0.5', 'fov:0.0', 'ao:true'] + muted) + '\n') + for world in (RUN / 'saves').glob('Shader-beam-*'): shutil.rmtree(world) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--minecraft-dir', type=Path) + parser.add_argument('--baseline', action='store_true') + parser.add_argument('--cases', default='') + parser.add_argument('--analyze-only', action='store_true') + args = parser.parse_args() + output = RUN / ('baseline' if args.baseline else 'fixed') + if not args.analyze_only: + if not args.minecraft_dir: parser.error('--minecraft-dir is required for cached inputs') + prepare(args.minecraft_dir) + if output.exists(): shutil.rmtree(output) + with (RUN / 'launch.log').open('w') as log: + result = subprocess.run(['./gradlew', '--offline', 'runShaderBeamSmoke', + f'-PshaderBeamBaseline={str(args.baseline).lower()}', f'-PshaderBeamCases={args.cases}'], + cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=960) + if result.returncode or not (output / 'result.txt').exists() or (output / 'result.txt').read_text() != 'CAPTURE_COMPLETE': + print(f'Render fixture failed; inspect {RUN / "launch.log"}', file=sys.stderr) + return 2 + return 0 if analyze(output) else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/main/java/com/ltdigor/flashlightfe/FlashlightConfig.java b/src/main/java/com/ltdigor/flashlightfe/FlashlightConfig.java index b4a3b02..25d098b 100644 --- a/src/main/java/com/ltdigor/flashlightfe/FlashlightConfig.java +++ b/src/main/java/com/ltdigor/flashlightfe/FlashlightConfig.java @@ -27,7 +27,7 @@ public final class FlashlightConfig { BEAM_RANGE = b.comment("Дальность луча в блоках. Мягкое рассеяние света Minecraft выходит за геометрию луча.") .worldRestart().defineInRange("beamRange", 12.0, 1.0, 32.0); CONE_ANGLE_DEGREES = b.comment("Полный угол конуса в градусах (не половина). Меньше угол — уже луч.") - .worldRestart().defineInRange("coneAngleDegrees", 35.0, 1.0, 90.0); + .worldRestart().defineInRange("coneAngleDegrees", 50.0, 1.0, 90.0); BEAM_BRIGHTNESS = b.comment("Максимальная яркость луча (уровень света 1–15).") .worldRestart().defineInRange("beamBrightness", 15, 1, 15); BEAM_SOFTNESS = b.comment("Мягкость края луча: 0 — чёткий круг, 1 — плавное затухание от центра.") diff --git a/src/main/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatch.java b/src/main/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatch.java new file mode 100644 index 0000000..7160d86 --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatch.java @@ -0,0 +1,54 @@ +package com.ltdigor.flashlightfe.client; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.regex.Pattern; + +/** Narrow, fail-closed adapter for the tested Complementary program structure. */ +public final class ComplementaryBeamPatch { + private static final Pattern FUNCTION = Pattern.compile("\\bvoid\\s+DoLighting\\s*\\("); + private static final Pattern DIFFUSE = Pattern.compile("\\bfinalDiffuse\\s*=\\s*sqrt\\s*\\(\\s*max\\s*\\(\\s*finalDiffuse\\s*,\\s*vec3\\s*\\(\\s*0\\.0\\s*\\)\\s*\\)\\s*\\)\\s*;"); + private static final String MARKER = "uniform vec4 bestflashlightBeamParams;"; + private static final String GLSL = readShader(); + + private ComplementaryBeamPatch() {} + + public static boolean supportedPack(String name) { + return "ComplementaryReimagined_r5.9.3.zip".equals(name) + || "ComplementaryReimagined_r5.9.3".equals(name); + } + + public static boolean surfaceProgram(String name) { + return name.startsWith("gbuffers_") && !name.startsWith("gbuffers_hand") + && !name.contains("sky") && !name.contains("cloud") && !name.contains("weather"); + } + + public static Optional patch(String pack, String program, String source) { + if (!supportedPack(pack) || !surfaceProgram(program) || source == null) return Optional.empty(); + if (source.contains(MARKER)) return Optional.of(source); + var function = FUNCTION.matcher(source); + var diffuse = DIFFUSE.matcher(source); + if (!function.find() || !diffuse.find()) return Optional.empty(); + int functionStart = function.start(), diffuseStart = diffuse.start(); + if (function.find() || diffuse.find() || functionStart >= diffuseStart + || !source.contains("blockLighting") || !source.contains("vanillaAO") + || !source.contains("directionShade")) return Optional.empty(); + String result = source.substring(0, diffuseStart) + // LDL mesh lighting may survive briefly while its chunk rebuild completes. + // Raise block illumination to the beam level without adding it twice. + + "finalDiffuse += max(vec3(0.0), bestflashlightSurfaceLight(playerPos) * blocklightCol" + + " - pow2(directionShade * vanillaAO) * blockLighting);\n" + + source.substring(diffuseStart); + return Optional.of(result.substring(0, functionStart) + GLSL + "\n" + result.substring(functionStart)); + } + + private static String readShader() { + try (var stream = ComplementaryBeamPatch.class.getResourceAsStream("/assets/bestflashlight/shaders/spotlight.glsl")) { + if (stream == null) throw new IllegalStateException("Missing spotlight shader"); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Cannot read spotlight shader", exception); + } + } +} diff --git a/src/main/java/com/ltdigor/flashlightfe/client/OptionalDynamicLights.java b/src/main/java/com/ltdigor/flashlightfe/client/OptionalDynamicLights.java index 98035cb..676590b 100644 --- a/src/main/java/com/ltdigor/flashlightfe/client/OptionalDynamicLights.java +++ b/src/main/java/com/ltdigor/flashlightfe/client/OptionalDynamicLights.java @@ -68,9 +68,25 @@ final class OptionalDynamicLights { private OptionalDynamicLights() {} + static boolean dynamicBeamMode() { + return available && !serverFallbackEnabled && isDynamicLightingEnabled(); + } + + /** Render-time handoff: camera changes can happen between client ticks. */ + static boolean releaseLocalCone(Player player) { + DynamicCone cone = CONES.remove(player.getUUID()); + if (cone != null && !removeCone(cone)) { + disable(); + return false; + } + return dynamicBeamMode(); + } + static void register() { NeoForge.EVENT_BUS.addListener(OptionalDynamicLights::fallbackMode); NeoForge.EVENT_BUS.addListener(OptionalDynamicLights::renderFrame); + NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) + -> ShaderBeamBridge.clearWorld()); } static void clientTick() { @@ -200,6 +216,7 @@ private static void updateTrackedPlayers(Minecraft client) { for (Player player : client.level.players()) { if (!player.isAlive() || player.isSpectator()) continue; + if (player == client.player && ShaderBeamBridge.replacesLocalCone()) continue; if (player != client.player && player.distanceToSqr(client.player) > maxDistanceSqr) continue; Vec3 look = player.getLookAngle(); @@ -207,13 +224,7 @@ private static void updateTrackedPlayers(Minecraft client) { look = look.normalize(); Vec3 eye = player.getEyePosition(); - Vec3 selectionLook = look; - Vec3 selectionEye = eye; - LampSource source = LampSource.select(player, candidate -> { - if (!LampEnergy.hasPower(candidate.stack(), player)) return false; - Vec3 candidateEmitter = emitterOrigin(player, candidate, selectionLook, selectionEye); - return FlashlightConfig.WORKS_UNDERWATER.get() || !isSubmerged(client.level, candidateEmitter); - }); + LampSource source = selectSource(client.level, player, look, eye); if (source == null) continue; Vec3 emitter = emitterOrigin(player, source, look, eye); @@ -260,6 +271,12 @@ private static boolean emitterPathClear(ClientLevel level, Player player, Vec3 e return hit.getType() == HitResult.Type.MISS; } + static LampSource selectSource(ClientLevel level, Player player, Vec3 look, Vec3 eye) { + return LampSource.select(player, candidate -> LampEnergy.hasPower(candidate.stack(), player) + && (FlashlightConfig.WORKS_UNDERWATER.get() + || !isSubmerged(level, emitterOrigin(player, candidate, look, eye)))); + } + private static Vec3 emitterOrigin(Player player, LampSource source, Vec3 look, Vec3 eye) { return emitterOrigin(player, source.headMounted(), source.offHand(), look, eye); } diff --git a/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamBridge.java b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamBridge.java new file mode 100644 index 0000000..413e137 --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamBridge.java @@ -0,0 +1,126 @@ +package com.ltdigor.flashlightfe.client; + +import com.ltdigor.flashlightfe.FlashlightConfig; +import com.ltdigor.flashlightfe.LampSource; +import com.mojang.logging.LogUtils; +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.function.Supplier; +import net.minecraft.client.Minecraft; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; +import org.joml.Vector4f; + +/** Optional Iris adapter. No Iris classes are linked when the mod is absent. */ +public final class ShaderBeamBridge { + private static final ShaderBeamSession SESSION = new ShaderBeamSession(); + private static ShaderBeamState state = ShaderBeamState.OFF; + private static Object currentPipeline; + private static boolean warned; + private static boolean disabled = Boolean.getBoolean("bestflashlight.shaderBeam.disabled"); + + private ShaderBeamBridge() {} + + public static void begin(Object pipeline) { + SESSION.begin(pipeline); + state = ShaderBeamState.OFF; + } + + public static void finish(Object pipeline) { + SESSION.finish(pipeline); + if (SESSION.ready(pipeline)) LogUtils.getLogger().info("Flashlight surface shader compiled successfully"); + } + + public static void destroy(Object pipeline) { + SESSION.destroy(pipeline); + if (currentPipeline == pipeline) { + currentPipeline = null; + state = ShaderBeamState.OFF; + } + } + + static void clearWorld() { + currentPipeline = null; + state = ShaderBeamState.OFF; + } + + public static Optional patch(String program, String source) { + if (disabled || source == null) return Optional.empty(); + try { + String pack = (String) Class.forName("net.irisshaders.iris.Iris").getMethod("getCurrentPackName").invoke(null); + if (!ComplementaryBeamPatch.supportedPack(pack)) return Optional.empty(); + Optional result = ComplementaryBeamPatch.patch(pack, program, source); + if (result.isPresent()) SESSION.patched(program); + else if (ComplementaryBeamPatch.surfaceProgram(program) + && (program.equals("gbuffers_terrain") || source.contains("DoLighting"))) { + SESSION.reject(); + warn("Unsupported Complementary lighting structure in " + program, null); + } + return result; + } catch (ReflectiveOperationException | LinkageError exception) { + SESSION.reject(); + warn("Could not identify Iris shader pack", exception); + return Optional.empty(); + } + } + + /** Called by Iris before rendering the world and before PER_FRAME uniform updates. */ + public static void frame(Object pipeline) { + currentPipeline = pipeline; + Minecraft mc = Minecraft.getInstance(); + if (disabled || !SESSION.ready(pipeline) || !OptionalDynamicLights.dynamicBeamMode() + || mc.level == null || mc.player == null || !mc.options.getCameraType().isFirstPerson() + || mc.getCameraEntity() != mc.player || !mc.player.isAlive() || mc.player.isSpectator()) { + state = ShaderBeamState.OFF; + return; + } + var camera = mc.gameRenderer.getMainCamera(); + LampSource lamp = OptionalDynamicLights.selectSource(mc.level, mc.player, + mc.player.getLookAngle(), mc.player.getEyePosition()); + if (lamp == null || !OptionalDynamicLights.releaseLocalCone(mc.player)) { + state = ShaderBeamState.OFF; + return; + } + var look = camera.getLookVector(); + state = new ShaderBeamState(true, Vec3.ZERO, new Vec3(look.x(), look.y(), look.z()).normalize(), + Math.clamp(FlashlightConfig.BEAM_RANGE.get(), 1, 32), + Math.toRadians(Math.clamp(FlashlightConfig.CONE_ANGLE_DEGREES.get(), 1, 90) / 2), + Math.clamp(FlashlightConfig.BEAM_BRIGHTNESS.get(), 1, 15), + Math.clamp(FlashlightConfig.BEAM_SOFTNESS.get(), 0, 1)); + } + + static boolean replacesLocalCone() { + Minecraft mc = Minecraft.getInstance(); + return state.active() && currentPipeline != null && SESSION.ready(currentPipeline) + && OptionalDynamicLights.dynamicBeamMode() && mc.options.getCameraType().isFirstPerson(); + } + + public static ShaderBeamState state() { return state; } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void registerUniforms(Object holder) { + if (disabled) return; + try { + Class frequency = Class.forName("net.irisshaders.iris.gl.uniform.UniformUpdateFrequency"); + Object perFrame = Enum.valueOf((Class) frequency, "PER_FRAME"); + Class api = Class.forName("net.irisshaders.iris.gl.uniform.UniformHolder"); + Method vector3 = api.getMethod("uniform3f", frequency, String.class, Supplier.class); + Method vector4 = api.getMethod("uniform4f", frequency, String.class, Supplier.class); + vector3.invoke(holder, perFrame, "bestflashlightBeamOrigin", (Supplier) () -> state.origin().toVector3f()); + vector3.invoke(holder, perFrame, "bestflashlightBeamDirection", (Supplier) () -> state.direction().toVector3f()); + vector4.invoke(holder, perFrame, "bestflashlightBeamParams", (Supplier) () -> state.active() + ? new Vector4f((float) state.range(), (float) Math.tan(state.halfAngle()), state.brightness() / 15f, (float) state.softness()) + : new Vector4f()); + SESSION.uniformsRegistered(); + } catch (ReflectiveOperationException | LinkageError exception) { + SESSION.reject(); + disabled = true; + state = ShaderBeamState.OFF; + warn("Could not register flashlight uniforms; retaining LDL lighting", exception); + } + } + + private static void warn(String message, Throwable exception) { + if (!warned) { warned = true; LogUtils.getLogger().warn(message, exception); } + } +} diff --git a/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamSession.java b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamSession.java new file mode 100644 index 0000000..8de024a --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamSession.java @@ -0,0 +1,30 @@ +package com.ltdigor.flashlightfe.client; + +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; + +/** Render-thread lifecycle; an aborted shader compilation never commits readiness. */ +public final class ShaderBeamSession { + private final Set ready = Collections.newSetFromMap(new WeakHashMap<>()); + private Object building; + private boolean terrain, uniforms, rejected; + + public void begin(Object pipeline) { + building = pipeline; + terrain = uniforms = rejected = false; + ready.remove(pipeline); + } + public void patched(String program) { if (program.equals("gbuffers_terrain")) terrain = true; } + public void uniformsRegistered() { uniforms = true; } + public void reject() { rejected = true; } + public void finish(Object pipeline) { + if (building == pipeline && terrain && uniforms && !rejected) ready.add(pipeline); + building = null; + } + public boolean ready(Object pipeline) { return ready.contains(pipeline); } + public void destroy(Object pipeline) { + ready.remove(pipeline); + if (building == pipeline) building = null; + } +} diff --git a/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamState.java b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamState.java new file mode 100644 index 0000000..12f0c46 --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/client/ShaderBeamState.java @@ -0,0 +1,9 @@ +package com.ltdigor.flashlightfe.client; + +import net.minecraft.world.phys.Vec3; + +/** Immutable per-frame input; no world access from a shader uniform supplier. */ +public record ShaderBeamState(boolean active, Vec3 origin, Vec3 direction, + double range, double halfAngle, int brightness, double softness) { + public static final ShaderBeamState OFF = new ShaderBeamState(false, Vec3.ZERO, new Vec3(0, 0, 1), 0, 0, 0, 0); +} diff --git a/src/main/java/com/ltdigor/flashlightfe/mixin/BestFlashlightMixinPlugin.java b/src/main/java/com/ltdigor/flashlightfe/mixin/BestFlashlightMixinPlugin.java index ed6fbca..7932248 100644 --- a/src/main/java/com/ltdigor/flashlightfe/mixin/BestFlashlightMixinPlugin.java +++ b/src/main/java/com/ltdigor/flashlightfe/mixin/BestFlashlightMixinPlugin.java @@ -13,6 +13,10 @@ public final class BestFlashlightMixinPlugin implements IMixinConfigPlugin { @Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + if (mixinClassName.substring(mixinClassName.lastIndexOf('.') + 1).startsWith("Iris")) { + return BestFlashlightMixinPlugin.class.getClassLoader() + .getResource("net/irisshaders/iris/Iris.class") != null; + } return !mixinClassName.endsWith("CuriosCommonEventsMixin") || curiosPresent(); } diff --git a/src/main/java/com/ltdigor/flashlightfe/mixin/IrisPipelineMixin.java b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisPipelineMixin.java new file mode 100644 index 0000000..8e5932f --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisPipelineMixin.java @@ -0,0 +1,24 @@ +package com.ltdigor.flashlightfe.mixin; + +import com.ltdigor.flashlightfe.client.ShaderBeamBridge; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.pipeline.IrisRenderingPipeline", remap = false) +public abstract class IrisPipelineMixin { + @Inject(method = "", at = @At("RETURN"), require = 0) + private void bestflashlight$compiled(CallbackInfo ci) { ShaderBeamBridge.finish(this); } + + @Inject(method = "", at = @At(value = "INVOKE", target = "Ljava/lang/Object;()V", shift = At.Shift.AFTER), require = 0) + private void bestflashlight$compiling(CallbackInfo ci) { ShaderBeamBridge.begin(this); } + + @Inject(method = "beginLevelRendering", at = @At("HEAD"), require = 0) + private void bestflashlight$frame(CallbackInfo ci) { ShaderBeamBridge.frame(this); } + + @Inject(method = "destroy", at = @At("HEAD"), require = 0) + private void bestflashlight$destroy(CallbackInfo ci) { ShaderBeamBridge.destroy(this); } +} diff --git a/src/main/java/com/ltdigor/flashlightfe/mixin/IrisProgramSourceMixin.java b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisProgramSourceMixin.java new file mode 100644 index 0000000..6c0160d --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisProgramSourceMixin.java @@ -0,0 +1,23 @@ +package com.ltdigor.flashlightfe.mixin; + +import com.ltdigor.flashlightfe.client.ShaderBeamBridge; +import java.util.Optional; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.shaderpack.programs.ProgramSource", remap = false) +public abstract class IrisProgramSourceMixin { + @Shadow @Final private String name; + + @Inject(method = "getFragmentSource", at = @At("RETURN"), cancellable = true, require = 0) + private void bestflashlight$surfaceShader(CallbackInfoReturnable> cir) { + cir.getReturnValue().ifPresent(source -> ShaderBeamBridge.patch(name, source) + .ifPresent(patched -> cir.setReturnValue(Optional.of(patched)))); + } +} diff --git a/src/main/java/com/ltdigor/flashlightfe/mixin/IrisUniformsMixin.java b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisUniformsMixin.java new file mode 100644 index 0000000..8f37424 --- /dev/null +++ b/src/main/java/com/ltdigor/flashlightfe/mixin/IrisUniformsMixin.java @@ -0,0 +1,19 @@ +package com.ltdigor.flashlightfe.mixin; + +import com.ltdigor.flashlightfe.client.ShaderBeamBridge; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Pseudo; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Coerce; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Pseudo +@Mixin(targets = "net.irisshaders.iris.uniforms.CommonUniforms", remap = false) +public abstract class IrisUniformsMixin { + @Inject(method = "addNonDynamicUniforms", at = @At("TAIL"), require = 0) + private static void bestflashlight$uniforms(@Coerce Object holder, @Coerce Object idMap, + @Coerce Object directives, @Coerce Object notifier, CallbackInfo ci) { + ShaderBeamBridge.registerUniforms(holder); + } +} diff --git a/src/main/resources/assets/bestflashlight/shaders/spotlight.glsl b/src/main/resources/assets/bestflashlight/shaders/spotlight.glsl new file mode 100644 index 0000000..f7012d5 --- /dev/null +++ b/src/main/resources/assets/bestflashlight/shaders/spotlight.glsl @@ -0,0 +1,29 @@ +// Camera-relative coordinates, in the same world axes as Complementary's playerPos. +uniform vec3 bestflashlightBeamOrigin; +uniform vec3 bestflashlightBeamDirection; +// Range, tan(half angle), brightness / 15, softness. Zero range disables the beam. +uniform vec4 bestflashlightBeamParams; + +float bestflashlightSurfaceLight(vec3 surfacePos) { + vec3 delta = surfacePos - bestflashlightBeamOrigin; + float forward = dot(delta, bestflashlightBeamDirection); + float range = bestflashlightBeamParams.x; + if (range <= 0.0 || forward <= 0.0 || forward >= range) return 0.0; + vec3 radialVector = delta - forward * bestflashlightBeamDirection; + float radius = forward * bestflashlightBeamParams.y; + float radial = length(radialVector) / max(radius, 0.000001); + float aa = max(fwidth(radial), 0.00001); + float edge = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, radial); + float softness = bestflashlightBeamParams.w; + float profile = edge; + if (softness > 0.0) { + profile *= pow(max(0.0, 1.0 - radial * radial), 2.0 * softness) + * smoothstep(0.0, softness, 1.0 - radial); + } + float endWidth = min(range, max(1.0, range * 0.2)); + float axial = smoothstep(0.0, endWidth, range - forward); + // Level-15 blocklight with the reference wall's 0.8^2 diffuse calibration. + // Keep this orientation-independent: ceilings must not acquire voxel shading seams. + // Applied before tone mapping, preserving material detail without clipping the core. + return 0.64 * pow(5.6, 2.25) * bestflashlightBeamParams.z * profile * axial; +} diff --git a/src/main/resources/bestflashlight.mixins.json b/src/main/resources/bestflashlight.mixins.json index 59d532e..1b170ac 100644 --- a/src/main/resources/bestflashlight.mixins.json +++ b/src/main/resources/bestflashlight.mixins.json @@ -9,6 +9,7 @@ "AbstractContainerMenuAccessor", "FlowingFluidMixin" ], + "client": ["IrisProgramSourceMixin", "IrisUniformsMixin", "IrisPipelineMixin"], "injectors": { "defaultRequire": 1 } diff --git a/src/smoke/java/com/ltdigor/flashlightfe/client/ShaderBeamSmoke.java b/src/smoke/java/com/ltdigor/flashlightfe/client/ShaderBeamSmoke.java new file mode 100644 index 0000000..c91c764 --- /dev/null +++ b/src/smoke/java/com/ltdigor/flashlightfe/client/ShaderBeamSmoke.java @@ -0,0 +1,278 @@ +package com.ltdigor.flashlightfe.client; + +import com.ltdigor.flashlightfe.FlashlightConfig; +import com.ltdigor.flashlightfe.FlashlightMod; +import com.ltdigor.flashlightfe.LampData; +import com.mojang.logging.LogUtils; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import net.minecraft.client.CameraType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.tutorial.TutorialSteps; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.world.Difficulty; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.GameRules; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.WorldDataConfiguration; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.levelgen.presets.WorldPresets; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.ModList; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RenderFrameEvent; + +/** Real renderer acceptance fixture. Its runner supplies the user's cached shader/mod files. */ +@EventBusSubscriber(modid = "bestflashlight", value = Dist.CLIENT) +public final class ShaderBeamSmoke { + private static final boolean ENABLED = Boolean.getBoolean("bestflashlight.shaderSmoke"); + private static final boolean BASELINE = Boolean.getBoolean("bestflashlight.shaderBeam.disabled"); + private static final String[] PHASES = {"off", "on", "reference"}; + private static final List CASES = cases(); + private static boolean opened, prepared, finished, capture; + private static boolean changingWorld, changedWorld; + private static int index = -1, phase = 2, ticks; + private static long started; + private static CompletableFuture pending; + private static Path output; + private record Case(String name, double distance, double angle, float pitch, float yaw, String source) {} + + private static List cases() { + var result = new ArrayList(); + for (double angle : new double[]{15, 35, 50}) for (double distance : new double[]{.5, 1, 2, 4, 8}) + result.add(new Case("wall-" + (int) angle + "-" + distance, distance, angle, 0, 0, "main")); + result.add(new Case("ceiling", 1, 35, -90, 0, "main")); + result.add(new Case("ceiling-yaw", 1, 35, -90, 135, "main")); + result.add(new Case("floor", 4, 35, 90, 0, "main")); + result.add(new Case("offhand", 4, 35, 0, 0, "off")); + result.add(new Case("headlamp", 4, 35, 0, 0, "head")); + result.add(new Case("reload", 4, 35, 0, 0, "reload")); + result.add(new Case("toggle-shaders", 4, 35, 0, 0, "toggle")); + result.add(new Case("third-person", 4, 35, 0, 0, "third")); + result.add(new Case("camera-handoff", 4, 35, 0, 0, "handoff")); + result.add(new Case("discharged", 4, 35, 0, 0, "empty")); + result.add(new Case("sloped-floor", 4, 35, 45, 0, "main")); + result.add(new Case("near-ceiling", .5, 15, -89, 45, "main")); + result.add(new Case("obstacles", 4, 35, 0, 0, "obstacles")); + result.add(new Case("world-change", 4, 35, 0, 0, "world")); + result.add(new Case("grid-offset", 4, 35, 0, 0, "offset")); + String selected = System.getProperty("bestflashlight.shaderSmoke.cases", ""); + if (!selected.isBlank()) result.removeIf(c -> !List.of(selected.split(",")).contains(c.name)); + if (result.isEmpty()) throw new IllegalArgumentException("No matching shader beam cases"); + return result; + } + + @SubscribeEvent public static void tick(ClientTickEvent.Post event) { + if (!ENABLED || finished || changingWorld) return; + var mc = Minecraft.getInstance(); + try { + if (started == 0) started = System.nanoTime(); + if (System.nanoTime() - started > 900_000_000_000L) throw new AssertionError("Shader smoke timeout"); + if (pending != null) { + if (!pending.isDone()) return; + pending.join(); pending = null; + } + if (!opened && mc.screen instanceof TitleScreen && mc.getOverlay() == null) { + if (changedWorld && ShaderBeamBridge.state().active()) + throw new AssertionError("Shader beam retained after leaving world"); + for (String mod : new String[]{"iris", "sodium", "lambdynlights"}) + if (!ModList.get().isLoaded(mod)) throw new AssertionError("Missing fixture mod: " + mod); + opened = true; + output = mc.gameDirectory.toPath().resolve(BASELINE ? "baseline" : "fixed"); + Files.createDirectories(output.resolve("screenshots")); + if (!Files.exists(output.resolve("cases.csv"))) + Files.writeString(output.resolve("cases.csv"), "case,phase,distance,angle,pitch,yaw,shaderActive\n"); + mc.options.hideGui = true; + mc.options.pauseOnLostFocus = false; + mc.options.fov().set(70); + mc.options.bobView().set(false); + mc.getTutorial().setStep(TutorialSteps.NONE); + mc.createWorldOpenFlows().createFreshLevel("Shader-beam-" + System.currentTimeMillis(), + new LevelSettings("Shader beam test", GameType.CREATIVE, false, Difficulty.PEACEFUL, true, + new GameRules(), WorldDataConfiguration.DEFAULT), new WorldOptions(4101L, false, false), + access -> access.registryOrThrow(Registries.WORLD_PRESET).getHolderOrThrow(WorldPresets.FLAT) + .value().createWorldDimensions(), mc.screen); + return; + } + if (mc.player == null || mc.level == null || mc.screen != null || mc.getOverlay() != null) return; + if (!prepared) { + prepared = true; + pending = mc.getSingleplayerServer().submit(() -> prepare(mc)); + return; + } + if (capture) return; + if (index >= 0 && CASES.get(index).source.equals("world") && phase == 1 && ticks == 40 && !changedWorld) { + if (!BASELINE && !ShaderBeamBridge.state().active()) throw new AssertionError("World-change probe has no active beam"); + changingWorld = true; + mc.level.disconnect(); + mc.disconnect(new TitleScreen()); + changedWorld = true; + opened = prepared = false; + ticks = -1; + changingWorld = false; + return; + } + if (ticks < 0) { + ticks = 0; + configure(mc); + } else if (index < 0 || ticks >= 80) { + if (++phase == PHASES.length) { phase = 0; index++; } + if (index == CASES.size()) { finish(mc, "CAPTURE_COMPLETE"); return; } + ticks = 0; + configure(mc); + } + pose(mc); + // Bulk replacement of reference LIGHT cells can leave a previously invisible + // Sodium section using its old lightmap. Rebuild after light packets settle. + if (ticks == 20) { + for (int z : new int[]{-1, 1}) LogUtils.getLogger().info("SHADER_BEAM_LIGHT_PROBE {} {} z={} client={}", + CASES.get(index).name, PHASES[phase], z, + mc.level.getBrightness(net.minecraft.world.level.LightLayer.BLOCK, new BlockPos(0, -57, z))); + mc.levelRenderer.allChanged(); + } + if (++ticks == 79) capture = true; + } catch (Throwable failure) { + LogUtils.getLogger().error("SHADER_BEAM_SMOKE_FAIL", failure); + finish(mc, "FAIL: " + failure); + } + } + + private static void prepare(Minecraft mc) { + var player = mc.getSingleplayerServer().getPlayerList().getPlayer(mc.player.getUUID()); + var level = player.serverLevel(); + level.setDayTime(18000); + level.getGameRules().getRule(GameRules.RULE_DAYLIGHT).set(false, level.getServer()); + level.getGameRules().getRule(GameRules.RULE_DOMOBSPAWNING).set(false, level.getServer()); + for (int x = -13; x <= 13; x++) for (int y = -59; y <= -29; y++) for (int z = -13; z <= 13; z++) { + boolean shell = x <= -12 || x >= 12 || y <= -58 || y >= -30 || z <= -12 || z >= 12; + level.setBlock(new BlockPos(x, y, z), (shell ? Blocks.WHITE_CONCRETE : Blocks.AIR).defaultBlockState(), 3); + } + player.getAbilities().flying = true; player.onUpdateAbilities(); + player.teleportTo(.5, -46.12, 8); + } + + private static void configure(Minecraft mc) throws Exception { + Case c = CASES.get(index); + mc.options.setCameraType(c.source.equals("third") || c.source.equals("handoff") && phase == 1 + ? CameraType.THIRD_PERSON_BACK : CameraType.FIRST_PERSON); + if (c.source.equals("reload") && phase == 0) + Class.forName("net.irisshaders.iris.Iris").getMethod("reload").invoke(null); + if (c.source.equals("toggle") && phase <= 1) { + Class iris = Class.forName("net.irisshaders.iris.Iris"); + Object config = iris.getMethod("getIrisConfig").invoke(null); + config.getClass().getMethod("setShadersEnabled", boolean.class).invoke(config, phase == 1); + config.getClass().getMethod("save").invoke(config); + iris.getMethod("reload").invoke(null); + } + FlashlightConfig.CONE_ANGLE_DEGREES.set(c.angle); FlashlightConfig.CONE_ANGLE_DEGREES.clearCache(); + FlashlightConfig.BEAM_RANGE.set(12.0); FlashlightConfig.BEAM_RANGE.clearCache(); + FlashlightConfig.BEAM_BRIGHTNESS.set(15); FlashlightConfig.BEAM_BRIGHTNESS.clearCache(); + FlashlightConfig.BEAM_SOFTNESS.set(.35); FlashlightConfig.BEAM_SOFTNESS.clearCache(); + int configuredPhase = phase; + pending = mc.getSingleplayerServer().submit(() -> { + var player = mc.getSingleplayerServer().getPlayerList().getPlayer(mc.player.getUUID()); + player.setGameMode(c.source.equals("empty") ? GameType.SURVIVAL : GameType.CREATIVE); + player.getAbilities().mayfly = true; + player.getAbilities().flying = true; + player.onUpdateAbilities(); + for (var slot : new EquipmentSlot[]{EquipmentSlot.MAINHAND, EquipmentSlot.OFFHAND, EquipmentSlot.HEAD}) + player.setItemSlot(slot, ItemStack.EMPTY); + if (configuredPhase == 1 || c.source.equals("toggle") && configuredPhase == 0) { + ItemStack lamp = new ItemStack(c.source.equals("head") ? FlashlightMod.HEADLAMP.get() : FlashlightMod.FLASHLIGHT.get()); + if (!c.source.equals("empty")) lamp.getCapability(Capabilities.EnergyStorage.ITEM).receiveEnergy(10000, false); + LampData.setEnabled(lamp, true); + player.setItemSlot(c.source.equals("head") ? EquipmentSlot.HEAD : c.source.equals("off") ? EquipmentSlot.OFFHAND : EquipmentSlot.MAINHAND, lamp); + } + // Full level-15 material reference, using vanilla invisible light blocks. + var level = player.serverLevel(); + for (int a = -11; a <= 11; a++) for (int b = -57; b <= -31; b++) + level.setBlock(new BlockPos(a, b, 11), (configuredPhase == 2 ? Blocks.LIGHT : Blocks.AIR).defaultBlockState(), 3); + for (int x = -11; x <= 11; x++) for (int z = -11; z <= 10; z++) for (int y : new int[]{-57, -31}) + level.setBlock(new BlockPos(x, y, z), (configuredPhase == 2 ? Blocks.LIGHT : Blocks.AIR).defaultBlockState(), 3); + level.setBlock(new BlockPos(0, -45, 10), (c.source.equals("obstacles") ? Blocks.BLACK_CONCRETE : Blocks.AIR).defaultBlockState(), 3); + level.setBlock(new BlockPos(-1, -45, 10), (c.source.equals("obstacles") ? Blocks.STONE_SLAB : Blocks.AIR).defaultBlockState(), 3); + level.setBlock(new BlockPos(1, -45, 10), (c.source.equals("obstacles") ? Blocks.STONE_STAIRS : Blocks.AIR).defaultBlockState(), 3); + }); + LogUtils.getLogger().info("SHADER_BEAM_CASE {} {}", c.name, PHASES[phase]); + } + + private static void pose(Minecraft mc) { + Case c = CASES.get(index); + double eyeY = c.pitch < 0 ? -30 - c.distance : c.pitch > 0 ? -57 + c.distance : -44.5; + double z = c.pitch == 0 ? 12 - c.distance : .5; + double x = c.source.equals("offset") ? .17 : .5; + if (c.source.equals("offset")) eyeY += .27; + mc.player.setPos(x, eyeY - mc.player.getEyeHeight(), z); + mc.player.xo = mc.player.getX(); mc.player.yo = mc.player.getY(); mc.player.zo = mc.player.getZ(); + mc.player.setDeltaMovement(0, 0, 0); + mc.player.getAbilities().flying = true; + mc.player.setXRot(c.pitch); mc.player.xRotO = c.pitch; + mc.player.setYRot(c.yaw); mc.player.yRotO = c.yaw; + } + + @SubscribeEvent public static void beforeFrame(RenderFrameEvent.Pre event) { + if (ENABLED && !finished && index >= 0 && index < CASES.size() + && CASES.get(index).source.equals("handoff") && phase == 1 && ticks >= 40) { + // Switch between client ticks: this specifically exercises the handoff race. + Minecraft.getInstance().options.setCameraType(CameraType.FIRST_PERSON); + } + } + + @SubscribeEvent public static void frame(RenderFrameEvent.Post event) { + if (!ENABLED || finished || changingWorld || index < 0 || index >= CASES.size()) return; + var mc = Minecraft.getInstance(); + try { + var transitionCones = OptionalDynamicLights.class.getDeclaredField("CONES"); + transitionCones.setAccessible(true); + if (!BASELINE && ShaderBeamBridge.state().active() + && !((java.util.Map) transitionCones.get(null)).isEmpty()) + throw new AssertionError("Double lighting during renderer handoff"); + if (!capture) return; + Case c = CASES.get(index); + if (!OptionalDynamicLights.dynamicBeamMode()) throw new AssertionError("LDL negotiation not active"); + Object manager = Class.forName("net.irisshaders.iris.Iris").getMethod("getPipelineManager").invoke(null); + Object pipeline = manager.getClass().getMethod("getPipelineNullable").invoke(manager); + boolean shaderPipeline = pipeline != null && pipeline.getClass().getName().equals("net.irisshaders.iris.pipeline.IrisRenderingPipeline"); + if (shaderPipeline != !(c.source.equals("toggle") && phase == 0)) + throw new AssertionError("Complementary shader pipeline is not active"); + boolean active = ShaderBeamBridge.state().active(); + boolean expectedBeam = phase == 1 && !c.source.equals("third") && !c.source.equals("empty"); + if (!BASELINE && active != expectedBeam) throw new AssertionError("Incorrect shader state: " + c.name + " " + PHASES[phase] + " active=" + active); + var conesField = OptionalDynamicLights.class.getDeclaredField("CONES"); + conesField.setAccessible(true); + var cones = (java.util.Map) conesField.get(null); + if (!BASELINE && active && !cones.isEmpty()) throw new AssertionError("Double lighting: local LDL cone still registered"); + if (c.source.equals("third") && phase == 1 && cones.isEmpty()) throw new AssertionError("Third-person LDL fallback missing"); + if (c.source.equals("toggle") && phase == 0 && cones.isEmpty()) throw new AssertionError("Shader-off LDL fallback missing"); + String file = c.name + "-" + PHASES[phase] + ".png"; + Screenshot.grab(output.toFile(), file, mc.getMainRenderTarget(), message -> {}); + Files.writeString(output.resolve("cases.csv"), c.name + "," + PHASES[phase] + "," + c.distance + "," + c.angle + + "," + c.pitch + "," + c.yaw + "," + active + "\n", StandardOpenOption.APPEND); + capture = false; + ticks = 80; + } catch (Throwable failure) { + LogUtils.getLogger().error("SHADER_BEAM_SMOKE_FAIL", failure); + finish(mc, "FAIL: " + failure); + } + } + + private static void finish(Minecraft mc, String result) { + finished = true; + try { if (output != null) Files.writeString(output.resolve("result.txt"), result); } + catch (Exception exception) { LogUtils.getLogger().error("Cannot save shader smoke result", exception); } + mc.stop(); + } +} diff --git a/src/test/java/com/ltdigor/flashlightfe/OptionalShaderClassloadingTest.java b/src/test/java/com/ltdigor/flashlightfe/OptionalShaderClassloadingTest.java new file mode 100644 index 0000000..adaa881 --- /dev/null +++ b/src/test/java/com/ltdigor/flashlightfe/OptionalShaderClassloadingTest.java @@ -0,0 +1,16 @@ +package com.ltdigor.flashlightfe; + +import com.ltdigor.flashlightfe.mixin.BestFlashlightMixinPlugin; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class OptionalShaderClassloadingTest { + @Test void noIrisRuntimeLoadsModAndSkipsEveryIrisMixin() { + assertThrows(ClassNotFoundException.class, () -> Class.forName("net.irisshaders.iris.Iris")); + for (String name : new String[]{"IrisProgramSourceMixin", "IrisUniformsMixin", "IrisPipelineMixin"}) { + assertFalse(new BestFlashlightMixinPlugin().shouldApplyMixin("unused", "com.ltdigor.flashlightfe.mixin." + name)); + } + assertDoesNotThrow(() -> Class.forName("com.ltdigor.flashlightfe.client.ShaderBeamBridge")); + assertDoesNotThrow(() -> Class.forName("com.ltdigor.flashlightfe.client.ComplementaryBeamPatch")); + } +} diff --git a/src/test/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatchTest.java b/src/test/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatchTest.java new file mode 100644 index 0000000..f9ebcc0 --- /dev/null +++ b/src/test/java/com/ltdigor/flashlightfe/client/ComplementaryBeamPatchTest.java @@ -0,0 +1,38 @@ +package com.ltdigor.flashlightfe.client; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ComplementaryBeamPatchTest { + private static final String PACK = "ComplementaryReimagined_r5.9.3.zip"; + // The two integration points, not a redistributed copy of the shader pack. + private static final String SOURCE = "#version 330 compatibility\n" + + "void DoLighting(inout vec4 color, vec3 playerPos) {\n" + + "vec3 finalDiffuse = pow2(directionShade * vanillaAO) * (blockLighting + pow2(sceneLighting) + minLighting) + pow2(emission);\n" + + "finalDiffuse = sqrt(max(finalDiffuse, vec3(0.0)));\n}\n"; + + @Test void addsSurfaceLightBeforeToneMappingWithoutChangingVersionDirective() { + String result = ComplementaryBeamPatch.patch(PACK, "gbuffers_terrain", SOURCE).orElseThrow(); + assertTrue(result.startsWith("#version 330 compatibility")); + assertTrue(result.contains("uniform vec4 bestflashlightBeamParams")); + assertTrue(result.indexOf("finalDiffuse +=") < result.indexOf("finalDiffuse = sqrt")); + assertTrue(result.contains("fwidth("), "Sub-pixel edge must be antialiased"); + assertFalse(result.contains("0.875"), "Pixel lighting must have no block-grid radius floor"); + assertFalse(result.contains("XLIGHT_I"), "Iris has already expanded pack macros before this hook"); + assertEquals(result, ComplementaryBeamPatch.patch(PACK, "gbuffers_terrain", result).orElseThrow()); + } + + @Test void unsupportedOrAmbiguousShadersStayUntouched() { + assertTrue(ComplementaryBeamPatch.patch("Other.zip", "gbuffers_terrain", SOURCE).isEmpty()); + assertTrue(ComplementaryBeamPatch.patch("ComplementaryReimagined_r5.9.4.zip", "gbuffers_terrain", SOURCE).isEmpty()); + assertTrue(ComplementaryBeamPatch.patch(PACK, "gbuffers_terrain", SOURCE.replace("finalDiffuse = sqrt", "other = sqrt")).isEmpty()); + assertTrue(ComplementaryBeamPatch.patch(PACK, "gbuffers_terrain", SOURCE + SOURCE).isEmpty()); + assertTrue(ComplementaryBeamPatch.patch(PACK, "gbuffers_hand", SOURCE).isEmpty()); + assertTrue(ComplementaryBeamPatch.patch(PACK, "shadow", SOURCE).isEmpty()); + } + + @Test void whitespaceFromPreprocessingDoesNotChangeTheIntegrationPoints() { + assertTrue(ComplementaryBeamPatch.patch(PACK, "gbuffers_terrain", + SOURCE.replace(" = ", "=").replace(" += ", "+=")).isPresent()); + } +} diff --git a/src/test/java/com/ltdigor/flashlightfe/client/ShaderBeamSessionTest.java b/src/test/java/com/ltdigor/flashlightfe/client/ShaderBeamSessionTest.java new file mode 100644 index 0000000..386e8b8 --- /dev/null +++ b/src/test/java/com/ltdigor/flashlightfe/client/ShaderBeamSessionTest.java @@ -0,0 +1,44 @@ +package com.ltdigor.flashlightfe.client; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ShaderBeamSessionTest { + @Test void compilationAndUniformRegistrationMustFinishBeforeTakingOverLdl() { + var session = new ShaderBeamSession(); + Object pipeline = new Object(); + session.begin(pipeline); + session.patched("gbuffers_terrain"); + assertFalse(session.ready(pipeline)); + session.uniformsRegistered(); + session.finish(pipeline); + assertTrue(session.ready(pipeline)); + session.destroy(pipeline); + assertFalse(session.ready(pipeline)); + } + + @Test void failureOrMissingTerrainNeverSuppressesFallback() { + for (boolean fail : new boolean[]{false, true}) { + var session = new ShaderBeamSession(); + Object pipeline = new Object(); + session.begin(pipeline); + session.patched(fail ? "gbuffers_terrain" : "gbuffers_entities"); + session.uniformsRegistered(); + if (fail) session.reject(); + session.finish(pipeline); + assertFalse(session.ready(pipeline)); + } + } + + @Test void dimensionPipelinesAndAbortedReloadsDoNotShareReadiness() { + var session = new ShaderBeamSession(); + Object first = new Object(), second = new Object(); + session.begin(first); session.patched("gbuffers_terrain"); + session.uniformsRegistered(); session.finish(first); + session.begin(second); session.patched("gbuffers_terrain"); + assertFalse(session.ready(second)); + assertTrue(session.ready(first)); + session.destroy(first); + assertFalse(session.ready(first)); + } +}