4 Commits

Author SHA1 Message Date
tkrmagid
d559c0c56a v0.4.23: fix wall z-fight and texture discard at distance
Some checks failed
build / build (push) Has been cancelled
Reported: with render_distance_blocks=256, the panel started shimmering
and the wall texture bled through at ~30 blocks away. Both issues are
inherent distance-rendering bugs that were previously hidden by the
default ~64-block view distance.

Two fixes in VideoAnchorRenderer:
1. SURFACE_EPSILON 0.001 → 0.02. With 24-bit depth and near=0.05, the
   smallest resolvable depth step at 30 blocks is ~1mm, so the old 1mm
   offset was right at the z-fight boundary. 2cm gives ~20× margin at
   30 blocks and remains visually unnoticeable up close.
2. RenderType entityCutout → entitySolid. swscale outputs RGBA with
   alpha=255, so there is no real cutout. Cutout's alpha-discard step
   makes distant sampling unstable on a dynamic non-mipmapped texture;
   solid removes that and is the semantically correct type.
2026-05-17 00:04:48 +09:00
tkrmagid
05aace294e v0.4.22: route video audio through Players sound category
Some checks failed
build / build (push) Has been cancelled
Previously the per-anchor audio gain was just volume × distance attenuation,
which ignored Minecraft's sound options sliders entirely — Master and Players
both had no effect on video audio. Now updateDistanceGains multiplies in
options.getSoundSourceVolume(MASTER) × getSoundSourceVolume(PLAYERS) to
match vanilla SoundEngine.calculateVolume, so the Players slider attenuates
video audio like other player sounds and Master gates everything.

Recomputed at the same 20Hz tick as distance gain — slider drags take
effect within ~50ms with no extra plumbing.
2026-05-16 23:46:07 +09:00
tkrmagid
41c7c48825 v0.4.21: extract publishIfNotCancelled, close main download publish race
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: v0.4.20 fixed the reindex branch's post-put race, but
the main download completion path still did READY.put without a follow-up
epoch check. /videoCache clear landing between READY.put and the chat
notification could leave a resurrected READY entry pointing at a deleted
file, plus a stale "완료" message.

Extracted publishIfNotCancelled(url, path, startEpoch) helper that does
the full pre-check → put → post-check → rollback dance, and routed both
publish sites (reindex branch + download-complete branch) through it.
Centralizing this is the structural fix: future publish sites can't forget
the post-check.
2026-05-16 22:57:52 +09:00
tkrmagid
1913181d02 v0.4.20: close reindex publish race + harden preload guard
Some checks failed
build / build (push) Has been cancelled
Reviewer-flagged: the existing-cache reindex branch in VideoCache.download
only ran one epoch check before READY.put, so a /videoCache clear landing
between the check and the put could leave a stale entry pointing at a
now-deleted file. Same pattern as the post-move fix in v0.4.19, applied
to the reindex path: pre-check, put, post-check + rollback on mismatch.

Also: preload() previously gated on READY.containsKey(url), which silently
blocks a re-preload if READY holds a stale key whose backing file is gone
(e.g. user deleted the file manually, or the cleanup half of a clear race).
Switched to lookup(url) — same intent, but lookup verifies the file
actually exists on disk, so stale keys self-heal on the next preload.
2026-05-16 22:54:20 +09:00
4 changed files with 75 additions and 20 deletions

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod
mod_id=video_player
mod_version=0.4.19
mod_version=0.4.23
maven_group=com.ejclaw.videoplayer
archives_base_name=video_player

View File

@@ -20,6 +20,7 @@ import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.phys.Vec3;
@@ -89,17 +90,26 @@ public class VideoPlayerClient implements ClientModInitializer {
* Distance is measured from the player's eye to the <em>panel center</em>, not the anchor
* block corner — for a 4×4 panel the corner is ~2 blocks off from where the screen visually
* sits, which made the audio feel like it was off to the side.
*
* <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as
* expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so
* we do the same with {@link SoundSource#PLAYERS} as the category. Result: dragging the
* "Players" slider in Options → Music & Sounds attenuates video audio just like other
* player sounds, and "Master" still gates everything.
*/
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER);
float playersVol = client.options.getSoundSourceVolume(SoundSource.PLAYERS);
float categoryScale = masterVol * playersVol;
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter();
double d = center.distanceTo(eye);
float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0));
float gain = be.isMuted() ? 0F : be.getVolume() * attenuation;
float gain = be.isMuted() ? 0F : be.getVolume() * attenuation * categoryScale;
VideoPlayback.setGain(pos, gain);
}
}

View File

@@ -190,7 +190,11 @@ public final class VideoCache {
public static void preload(String url) {
if (url == null || url.isEmpty()) return;
if (!(url.startsWith("http://") || url.startsWith("https://"))) return;
if (READY.containsKey(url)) {
// Use lookup() (which also verifies the file exists on disk) instead of
// READY.containsKey — defends against READY containing a stale entry whose
// backing file was removed by clearAll() / a user-side file delete. Without this,
// a stale key would silently block a re-preload.
if (lookup(url) != null) {
VideoPlayerMod.LOG.info("[{}] preload: already cached {}", VideoPlayerMod.MOD_ID, url);
notifyChat("[videopreload] 이미 캐시됨: " + url, ChatFormatting.GRAY);
return;
@@ -229,6 +233,33 @@ public final class VideoCache {
// -- internals -----------------------------------------------------------------------
/**
* Publish {@code path} into {@link #READY} for {@code url}, but only if the cache
* epoch hasn't moved since {@code startEpoch} was captured. Wraps the
* pre-check / put / post-check / rollback dance so every publish site uses the
* same race-free pattern instead of re-implementing it.
*
* <p>Race coverage: if {@link #clearAll()} runs at any point between the
* pre-check and the post-check, the post-check sees the epoch bump and we
* roll back — {@link Map#remove(Object, Object)} is a compareAndRemove so we
* don't clobber a legitimate concurrent put under the same key, and we delete
* the on-disk file because clearAll's directory scan may have missed it (the
* file may not have existed yet when clearAll ran).
*
* @return {@code true} if the entry is now published; {@code false} if the
* caller's work was cancelled and {@code path} has been cleaned up.
*/
private static boolean publishIfNotCancelled(String url, Path path, long startEpoch) {
if (CACHE_EPOCH.get() != startEpoch) return false;
READY.put(url, path);
if (CACHE_EPOCH.get() != startEpoch) {
READY.remove(url, path);
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
return true;
}
private static void download(String url, long startEpoch) {
Path cacheDir = cacheDir();
Path partPath = null;
@@ -255,8 +286,11 @@ public final class VideoCache {
// Resume-friendly: if the file's already on disk from an earlier session, just
// index it without re-downloading.
if (Files.exists(finalPath) && Files.size(finalPath) > 0) {
if (CACHE_EPOCH.get() != startEpoch) return;
READY.put(url, finalPath);
if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
VideoPlayerMod.LOG.info("[{}] preload: indexed existing cache {} -> {}",
VideoPlayerMod.MOD_ID, url, finalPath.getFileName());
notifyChat("[videopreload] 기존 캐시 사용: " + url, ChatFormatting.GREEN);
@@ -352,21 +386,16 @@ public final class VideoCache {
Files.move(partPath, finalPath, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
// Post-move cancellation check. clearAll() may have run between the pre-move
// check above and the move itself — in that case clearAll's directory scan
// either missed our file (it didn't exist yet) or saw the .part and skipped /
// failed to delete it. Either way, finalPath now exists on disk but the
// user-visible cache state is "cleared", so we must delete the file and skip
// both the READY.put and the "완료" chat. Without this check, a clear right at
// this window leaves a resurrected file in READY and a stale "완료" message.
if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled after move (clearAll ran) — {}",
// Race-safe publish: this covers the window between Files.move and READY.put
// (clearAll deletes the freshly-moved file then we resurrect it in READY) AND
// the window between READY.put and the "완료" notification (clearAll wipes
// READY then we re-publish + emit stale completion chat). publishIfNotCancelled
// rolls back both READY and the on-disk file if the epoch moved at any time.
if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}",
VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(finalPath); } catch (Throwable ignored) {}
return;
}
READY.put(url, finalPath);
VideoPlayerMod.LOG.info("[{}] preload: cached {} ({} bytes) -> {}",
VideoPlayerMod.MOD_ID, url, total, finalPath.getFileName());
long mb = Math.max(1, total / (1024 * 1024));

View File

@@ -31,8 +31,19 @@ import org.joml.Matrix4f;
@Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Tiny outward offset so the quad doesn't z-fight with the wall. */
private static final float SURFACE_EPSILON = 0.001F;
/**
* Outward offset so the quad doesn't z-fight with the wall it sits on.
*
* <p>Depth-buffer precision drops with the square of view distance: with near=0.05 and
* 24-bit depth, the smallest resolvable step at 30 blocks is ~1mm, and ~12mm at 100
* blocks. The old 0.001 (1mm) offset was right at the precision boundary at ~30 blocks
* — which is exactly the distance users started seeing the wall texture flicker through
* the video panel once {@code render_distance_blocks} was raised past the default.
*
* <p>2cm gives ~20× margin at 30 blocks and is still visually unnoticeable up close
* (~3% of a block thickness).
*/
private static final float SURFACE_EPSILON = 0.02F;
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op
@@ -75,7 +86,12 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON);
final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex);
// entitySolid (not entityCutout): video frames come from swscale → AV_PIX_FMT_RGBA with
// alpha hard-set to 255, so there is no alpha-tested cutout. Cutout's alpha-discard step
// adds nothing here and makes distant sampling unstable — without mipmaps on a dynamic
// texture, neighbouring texels can shimmer above/below the discard threshold at sub-pixel
// sampling rates, contributing to the flicker users see once render distance is raised.
RenderType rt = RenderTypes.entitySolid(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Single-sided: the back of the anchor is by design pressed against the wall the
// player clicked, so a back face is pure GPU waste. Halves the fragment shader work