diff --git a/README.md b/README.md index a6f3b0d..5a6034f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ 마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드. - 모드 ID: `video_player` -- 현재 버전: **0.4.9** +- 현재 버전: **0.4.10** - 마인크래프트 버전: **26.1.2** - 필요 Java: **25** (마인크래프트 26.x 가 요구함) @@ -51,23 +51,23 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 https://cdn.modrinth.com/data/P7dR8mSH/versions/Sy2Bq7Xc/fabric-api-0.149.0%2B26.1.2.jar - 더 최신 빌드를 찾을 땐: https://modrinth.com/mod/fabric-api/versions → 페이지에서 게임 버전 필터 `26.1.2` 를 직접 선택. (URL 파라미터 필터가 듣지 않는 경우가 있어서 페이지 안에서 한 번 더 확인하는 게 안전합니다.) - 받은 `fabric-api-0.149.0+26.1.2.jar` 를 `mods` 폴더에 넣습니다. -2. **video_player** (이 모드, 0.4.9 부터 JavaCV 가 jar 안에 포함됨) +2. **video_player** (이 모드, 0.4.10 부터 JavaCV 가 jar 안에 포함됨) - 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases - 자신의 OS·CPU 에 맞는 jar **한 개** 만 받아서 `mods` 폴더에 넣으면 됩니다 (별도 JavaCV 설치 불필요): - - Windows 64bit: `video_player-windows-x86_64-0.4.9.jar` (~32MB) - - macOS Intel: `video_player-macosx-x86_64-0.4.9.jar` (~24MB) - - macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.9.jar` (~21MB) - - Linux 64bit: `video_player-linux-x86_64-0.4.9.jar` (~27MB) + - Windows 64bit: `video_player-windows-x86_64-0.4.10.jar` (~32MB) + - macOS Intel: `video_player-macosx-x86_64-0.4.10.jar` (~24MB) + - macOS Apple Silicon (M1/M2/M3/M4): `video_player-macosx-arm64-0.4.10.jar` (~21MB) + - Linux 64bit: `video_player-linux-x86_64-0.4.10.jar` (~27MB) - 자기 OS 가 헷갈리면: Windows 는 거의 다 `windows-x86_64`, 인텔맥은 `macosx-x86_64`, 애플 실리콘 맥은 `macosx-arm64`, 리눅스는 `linux-x86_64`. -이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. 0.4.7 이하에서 쓰던 JVM 인수(`-Xbootclasspath/a:...javacv...`) 도 0.4.9 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다. +이전 버전(`video_player-0.4.0.jar`, `0.4.2.jar`, `0.4.3.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다. 0.4.7 이하에서 쓰던 JVM 인수(`-Xbootclasspath/a:...javacv...`) 도 0.4.10 부터는 **빼주세요** — 모드 jar 안에 같은 JavaCV 가 들어있어서 부트클래스패스의 것과 충돌해 검은 화면이 날 수 있습니다. ### STEP 5. 잘 설치됐는지 확인 게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면: - 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘). -- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.9` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.) +- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar(`video_player-0.4.0.jar` / `0.4.1.jar` 등)가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 `0.4.10` 만 남기고 다시 시작하세요. (0.4.1 이하는 Fabric 26.1.2 model 로더가 unprefixed `item/generated` parent 를 거부해서 스틱 아이콘이 missing-model 큐브로 보입니다 — 0.4.2 에서 수정됨.) --- @@ -172,7 +172,7 @@ Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니 ```sh JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build ``` -산출물: `build/libs/video_player-0.4.9.jar` (~85KB) +산출물: `build/libs/video_player-0.4.10.jar` (~85KB) 플랫폼별 fat jar (JavaCV 1.5.13 + ffmpeg 8.0.1 네이티브 nested): ```sh @@ -181,7 +181,7 @@ JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=li JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-x86_64 JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew clean build -Pplatform=macosx-arm64 ``` -산출물: `build/libs/video_player--0.4.9.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩) +산출물: `build/libs/video_player--0.4.10.jar` (~21-32MB, jar 내부에 nested 로 javacv/javacpp/ffmpeg jar 5개 포함, Fabric loader 가 런타임에 classpath 로 풀어서 로딩) JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표: ``` diff --git a/gradle.properties b/gradle.properties index fc60f0a..74b13e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.configuration-cache=false # Mod mod_id=video_player -mod_version=0.4.9 +mod_version=0.4.10 maven_group=com.ejclaw.videoplayer archives_base_name=video_player diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java index 4f6c65f..e11c010 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/JavaCvBackend.java @@ -4,6 +4,8 @@ import com.ejclaw.videoplayer.VideoPlayerMod; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; +import org.lwjgl.system.MemoryUtil; + import javax.sound.sampled.AudioFormat; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.SourceDataLine; @@ -13,7 +15,6 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.ShortBuffer; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; /** * SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber. @@ -38,7 +39,17 @@ public class JavaCvBackend implements VideoBackend { private Thread worker; private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean paused = new AtomicBoolean(false); - private final AtomicReference latest = new AtomicReference<>(); + /** + * Single preallocated RGBA staging buffer. Decoder thread writes into it under + * {@link #frameLock}; render thread reads via {@link #consumeFrame(long, long)} under the + * same lock. One allocation for the lifetime of the backend instead of one per frame — + * see 0.4.10 changelog for the regression that motivated this. The lock is short-held + * (one 8MB memcpy ≈ 1ms at 1080p) so contention is negligible. + */ + private final Object frameLock = new Object(); + private ByteBuffer frameBuf; + private int frameBufBytes = 0; + private boolean frameDirty = false; private volatile int width = 0; private volatile int height = 0; private volatile float gain = 1.0F; @@ -88,14 +99,32 @@ public class JavaCvBackend implements VideoBackend { public int videoHeight() { return height; } @Override - public ByteBuffer pollFrame() { - return latest.getAndSet(null); + public boolean consumeFrame(long dstAddr, long maxBytes) { + synchronized (frameLock) { + if (!frameDirty || frameBuf == null || frameBufBytes <= 0) return false; + if (frameBufBytes > maxBytes) { + // Texture not yet resized for this frame's dimensions — drop and wait for the + // caller to ensure capacity next tick. ensureTexture() runs in Entry.upload + // before consumeFrame, so this is only hit on the exact tick of a resolution + // change. + frameDirty = false; + return false; + } + MemoryUtil.memCopy(MemoryUtil.memAddress(frameBuf), dstAddr, frameBufBytes); + frameDirty = false; + return true; + } } @Override public void close() { closed = true; stopWorker(); + synchronized (frameLock) { + frameBuf = null; + frameBufBytes = 0; + frameDirty = false; + } } private void stopWorker() { @@ -214,17 +243,34 @@ public class JavaCvBackend implements VideoBackend { Object[] images = (Object[]) imageField.get(frame); if (images != null && images.length > 0 && images[0] instanceof ByteBuffer src) { // frame.image[0] is the swscale-converted RGBA plane, reused by the grabber - // across grab() calls. Copy into a fresh direct buffer because the render - // thread reads `latest` asynchronously and would otherwise see a buffer - // already being overwritten by the next grab(). + // across grab() calls. Copy into our preallocated staging buffer under + // frameLock so the render thread's consumeFrame() sees a coherent image. + // + // 0.4.9 used `ByteBuffer.allocateDirect(w*h*4)` on every grab — at 1080p × + // 24fps that's ~192 MB/s of direct memory churn (each allocation zero-fills + // the page, plus the Cleaner enqueues the old buffer for finalization). + // The decoder thread spent so much time on memory bookkeeping that grab() + // fell behind real time, the single-slot `latest` AtomicReference was + // refilled in bursts, and the user saw ~5fps playback even though the + // game/render thread was fine. + // + // Preallocating once eliminates both the zero-fill cost and the Cleaner + // pressure. The decoder thread now spends its budget on the actual decode + + // swscale + a single 8MB memcpy — well within 42ms at 1080p × 24fps. int need = src.remaining(); if (need > 0) { - ByteBuffer copy = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder()); - int srcPos = src.position(); - copy.put(src); - src.position(srcPos); // restore so JavaCV's own bookkeeping isn't disturbed - copy.flip(); - latest.set(copy); + synchronized (frameLock) { + if (frameBuf == null || frameBuf.capacity() < need) { + frameBuf = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder()); + } + int srcPos = src.position(); + long dstAddr = MemoryUtil.memAddress(frameBuf); + long srcAddr = MemoryUtil.memAddress(src) + srcPos; + MemoryUtil.memCopy(srcAddr, dstAddr, need); + src.position(srcPos); // unchanged, but explicit — JavaCV reads it too + frameBufBytes = need; + frameDirty = true; + } } } diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java index b759fcb..815ddcb 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoBackend.java @@ -3,8 +3,6 @@ package com.ejclaw.videoplayer.client.playback; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; -import java.nio.ByteBuffer; - /** * SPEC §5.3 — minimal playback backend abstraction. Implementations: WatermediaBackend (preferred, * when v2 supports the target MC version) and JavaCvBackend (fallback). @@ -21,10 +19,19 @@ public interface VideoBackend { int videoHeight(); /** - * Poll a new decoded RGBA frame if one is ready. - * @return the frame buffer (capacity = w*h*4) or {@code null} if no new frame is ready. + * If a new RGBA frame is ready, memcpy it directly into the GPU texture buffer at + * {@code dstAddr} (must have room for at least {@code w*h*4} bytes) and clear the dirty + * flag. Returns {@code true} when a frame was written. + * + *

Replaces the prior {@code pollFrame()} which returned a {@link java.nio.ByteBuffer}. + * The old contract forced the decoder to either allocate a fresh direct buffer per frame + * (huge memory churn at 1080p — see 0.4.10 changelog) or expose a reused buffer whose + * memory the decoder could clobber while the renderer was still reading. Pushing the copy + * inside the backend lets the decoder hold a single preallocated buffer under its own + * lock and copy out to the GPU pointer in one synchronized block — zero allocation, no + * race window. */ - ByteBuffer pollFrame(); + boolean consumeFrame(long dstAddr, long maxBytes); void close(); } diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java index b272c76..db2dfb9 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/VideoPlayback.java @@ -9,9 +9,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.texture.DynamicTexture; import net.minecraft.core.BlockPos; import net.minecraft.resources.Identifier; -import org.lwjgl.system.MemoryUtil; -import java.nio.ByteBuffer; import java.nio.file.Path; import java.util.HashMap; import java.util.HashSet; @@ -113,10 +111,8 @@ public final class VideoPlayback { continue; } if (!e.backend.isReady()) continue; - ByteBuffer buf = e.backend.pollFrame(); - if (buf == null) continue; try { - e.upload(buf); + e.tryUpload(); } catch (Throwable t) { VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString()); e.close(); @@ -188,23 +184,23 @@ public final class VideoPlayback { } } - /** Copy an incoming RGBA byte buffer into the texture, resizing if dimensions changed. */ - void upload(ByteBuffer rgba) { + /** + * If the backend has a new RGBA frame, copy it straight into the texture's native + * pixel buffer and re-upload to GPU. The backend does the memcpy under its own lock + * so we never read a half-written frame. RGBA bytes already match NativeImage's + * ABGR-int layout in little-endian byte order (byte 0 = R = low byte of the int). + */ + void tryUpload() { int w = backend.videoWidth(); int h = backend.videoHeight(); if (w <= 0 || h <= 0) return; ensureTexture(w, h, false); NativeImage img = texture.getPixels(); if (img == null) return; - - // RGBA bytes from the backend already match NativeImage's ABGR-int layout when - // viewed as little-endian bytes: byte 0 = R (low byte of ABGR int), byte 1 = G, - // byte 2 = B, byte 3 = A. So a flat memcpy works — no per-pixel swap needed. - // This replaces a 2M-iteration Java loop with one native memcpy for 1080p frames, - // cutting upload time from ~20ms to <1ms and removing the main stutter source. - long bytes = (long) w * h * 4L; - MemoryUtil.memCopy(MemoryUtil.memAddress(rgba), img.getPointer(), bytes); - texture.upload(); + long maxBytes = (long) w * h * 4L; + if (backend.consumeFrame(img.getPointer(), maxBytes)) { + texture.upload(); + } } void close() { diff --git a/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java index 9d68bf8..f598bef 100644 --- a/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java +++ b/src/main/java/com/ejclaw/videoplayer/client/playback/WatermediaBackend.java @@ -4,8 +4,6 @@ import com.ejclaw.videoplayer.VideoPlayerMod; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; -import java.nio.ByteBuffer; - /** * SPEC §5.3 / §5.4 — WaterMedia v2 backend. Reflection-only so the mod jar stays clean of * compile-time WaterMedia dependencies. Until a v2 build supports 1.21.6+ this returns @@ -38,8 +36,8 @@ public class WatermediaBackend implements VideoBackend { @Override public int videoHeight() { return height; } @Override - public ByteBuffer pollFrame() { - return null; // no frames until v2 is wired up + public boolean consumeFrame(long dstAddr, long maxBytes) { + return false; // no frames until v2 is wired up } @Override