render: restore textured quad on new 26.1.2 BlockEntityRenderer pipeline

VideoPlayback now allocates a DynamicTexture per active anchor under a unique
Identifier (registered on Minecraft.getTextureManager()) and pumps RGBA frames
into it via NativeImage.setPixelABGR + DynamicTexture.upload() during the
client tick. Until the backend (JavaCV) produces a first frame, the texture
shows a dark gray placeholder with a thin border so the anchor screen is
visibly present.

VideoAnchorRenderer.submit() now uses SubmitNodeCollector.submitCustomGeometry
with RenderTypes.entityCutout(textureId), drawing a two-sided width×height
quad oriented by Direction.toYRot() + Axis.YP.rotationDegrees. Vertex
attributes use the new VertexConsumer fluent API (addVertex(Matrix4f, ...)
.setColor.setUv.setOverlay(NO_OVERLAY).setLight.setNormal).

JavaCvBackend / WatermediaBackend / WatermediaProbe / VideoBackend are
unchanged — JavaCV is referenced entirely via reflection so the mod jar
remains loadable when the bytedeco classifier jars aren't on the runtime
classpath, in which case the anchor renders its placeholder surface.
This commit is contained in:
tkrmagid
2026-05-15 19:38:23 +09:00
parent 27a3f34bfa
commit d5d5a6ff81
2 changed files with 210 additions and 37 deletions

View File

@@ -1,64 +1,110 @@
package com.ejclaw.videoplayer.client.playback; package com.ejclaw.videoplayer.client.playback;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.mojang.blaze3d.platform.NativeImage;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import java.nio.ByteBuffer;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* SPEC §5 — per-anchor playback registry. * SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} +
* a {@link DynamicTexture} surface registered under a unique {@link Identifier}). The renderer
* reads {@link #currentTexture(BlockPos)} and binds it to the quad. {@link #tick()} pumps
* decoded RGBA frames into the texture.
* *
* <p><b>Status (26.1.2 port):</b> the rendering pipeline was rewritten upstream * <p>When no backend is available on the classpath (e.g. JavaCV jar not installed by the
* (render-state separation, removal of {@code DrawContext}/{@code GuiGraphics}). * user), the texture stays at its initial placeholder pattern, so the anchor's screen quad
* Until the new {@code BlockEntityRenderer<T, S>} pipeline is wired through with a * still renders as a visible surface.
* dynamic texture surface, this class operates as a stub: it tracks active anchors
* but does not decode frames or upload textures. Audio backends are also paused
* pending a Java 25 dependency audit (Watermedia / JavaCV).
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public final class VideoPlayback { public final class VideoPlayback {
private VideoPlayback() {} private VideoPlayback() {}
private static final Map<BlockPos, String> ENTRIES = new HashMap<>(); private static final int PLACEHOLDER_SIZE = 32;
private static final Map<BlockPos, Entry> ENTRIES = new HashMap<>();
/**
* Ensure a playback entry exists for this anchor and return its texture identifier.
* Creates a backend + dynamic texture on first call. Returns {@code null} only if the
* URL is empty or autoplay is off.
*/
public static Identifier getOrStart(VideoAnchorBlockEntity be) { public static Identifier getOrStart(VideoAnchorBlockEntity be) {
BlockPos pos = be.getBlockPos(); BlockPos pos = be.getBlockPos();
String url = be.getUrl(); Entry e = ENTRIES.get(pos);
if (url == null || url.isEmpty() || !be.isAutoplay()) {
ENTRIES.remove(pos); if (be.getUrl().isEmpty() || !be.isAutoplay()) {
if (e != null) {
stop(pos);
}
return null; return null;
} }
ENTRIES.put(pos, url);
return null; // no dynamic texture yet in 26.1.2 port if (e != null && e.url.equals(be.getUrl())) {
return e.id;
}
if (e != null) {
stop(pos);
}
VideoBackend backend = WatermediaProbe.isAvailable() ? new WatermediaBackend() : new JavaCvBackend();
backend.play(be.getUrl(), be.isLoop());
backend.setVolume(be.isMuted() ? 0F : be.getVolume());
Entry created = new Entry(be.getUrl(), backend);
ENTRIES.put(pos, created);
return created.id;
} }
public static Identifier currentTexture(BlockPos pos) { public static Identifier currentTexture(BlockPos pos) {
return null; Entry e = ENTRIES.get(pos);
return e == null ? null : e.id;
} }
public static void stop(BlockPos pos) { public static void stop(BlockPos pos) {
ENTRIES.remove(pos); Entry e = ENTRIES.remove(pos);
if (e != null) e.close();
} }
public static void onConfigChanged(VideoAnchorBlockEntity be) { public static void onConfigChanged(VideoAnchorBlockEntity be) {
if (be == null) return; if (be == null) return;
BlockPos pos = be.getBlockPos(); Entry e = ENTRIES.get(be.getBlockPos());
String url = be.getUrl(); if (e == null) return;
if (url == null || url.isEmpty()) { if (!e.url.equals(be.getUrl())) {
ENTRIES.remove(pos); stop(be.getBlockPos());
} else { return;
ENTRIES.put(pos, url);
} }
e.backend.setVolume(be.isMuted() ? 0F : be.getVolume());
} }
/** Called every client tick to upload new frames into the GPU texture. */
public static void tick() { public static void tick() {
// stub: no frames to pump if (Minecraft.getInstance() == null) return;
Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<BlockPos, Entry> me = it.next();
Entry e = me.getValue();
if (!e.backend.isReady()) continue;
ByteBuffer buf = e.backend.pollFrame();
if (buf == null) continue;
try {
e.upload(buf);
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString());
e.close();
it.remove();
}
}
} }
public static Set<BlockPos> activePositions() { public static Set<BlockPos> activePositions() {
@@ -66,10 +112,92 @@ public final class VideoPlayback {
} }
public static void setGain(BlockPos pos, float gain) { public static void setGain(BlockPos pos, float gain) {
// stub Entry e = ENTRIES.get(pos);
if (e != null) e.backend.setVolume(gain);
} }
public static void stopAll() { public static void stopAll() {
for (Entry e : ENTRIES.values()) e.close();
ENTRIES.clear(); ENTRIES.clear();
} }
/** Per-anchor playback state. */
private static final class Entry {
final String url;
final VideoBackend backend;
final Identifier id;
DynamicTexture texture;
int texW = 0, texH = 0;
boolean registered = false;
Entry(String url, VideoBackend backend) {
this.url = url;
this.backend = backend;
String tag = Integer.toHexString(System.identityHashCode(this));
this.id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "dynamic/" + tag);
ensureTexture(PLACEHOLDER_SIZE, PLACEHOLDER_SIZE, true);
}
/** Allocate or resize the dynamic texture, registering it on first allocation. */
private void ensureTexture(int w, int h, boolean fillPlaceholder) {
if (texture != null && w == texW && h == texH) return;
if (texture != null) texture.close();
NativeImage img = new NativeImage(w, h, false);
if (fillPlaceholder) {
fillPlaceholder(img, w, h);
}
texture = new DynamicTexture(() -> "video_player:" + id, img);
texW = w;
texH = h;
Minecraft mc = Minecraft.getInstance();
if (mc != null) {
mc.getTextureManager().register(id, texture);
registered = true;
}
texture.upload();
}
/** Dark gray surface with a thin border — visible "screen" until first frame arrives. */
private static void fillPlaceholder(NativeImage img, int w, int h) {
// ABGR int. 0xAABBGGRR.
int body = 0xFF202020; // dark gray
int border = 0xFF505050;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
boolean isEdge = (x == 0 || y == 0 || x == w - 1 || y == h - 1);
img.setPixelABGR(x, y, isEdge ? border : body);
}
}
}
/** Copy an incoming RGBA byte buffer into the texture, resizing if dimensions changed. */
void upload(ByteBuffer rgba) {
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;
int pixels = w * h;
for (int i = 0; i < pixels; i++) {
int r = rgba.get() & 0xFF;
int g = rgba.get() & 0xFF;
int b = rgba.get() & 0xFF;
int a = rgba.get() & 0xFF;
int abgr = (a << 24) | (b << 16) | (g << 8) | r;
img.setPixelABGR(i % w, i / w, abgr);
}
texture.upload();
}
void close() {
try { backend.close(); } catch (Throwable ignored) {}
if (texture != null) {
try { texture.close(); } catch (Throwable ignored) {}
texture = null;
}
// texture manager keeps the registration; the texture itself is closed.
}
}
} }

View File

@@ -3,6 +3,7 @@ package com.ejclaw.videoplayer.client.render;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity; import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.playback.VideoPlayback; import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.fabricmc.api.EnvType; import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment; import net.fabricmc.api.Environment;
import net.minecraft.client.renderer.SubmitNodeCollector; import net.minecraft.client.renderer.SubmitNodeCollector;
@@ -10,24 +11,26 @@ import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer; import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.state.level.CameraRenderState; import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
/** /**
* SPEC §5.2 — anchor renderer. * SPEC §5.2 — submits a width×height textured quad in front of the anchor, oriented by facing.
* *
* <p><b>Status (26.1.2 port):</b> the rendering pipeline was rewritten upstream to use * <p>Ported to 26.1.2's render-state pipeline: per-frame BE state is captured in
* a render-state separation pattern ({@code createRenderState} / {@code extractRenderState} * {@link State} via {@link #extractRenderState}, then drawn via
* / {@code submit} via {@link SubmitNodeCollector}). This stub implements the new interface * {@link SubmitNodeCollector#submitCustomGeometry} during {@link #submit}.
* but draws nothing — the anchor block itself is the only visible element. Frame upload
* and quad submission will be re-introduced once the dynamic texture surface is wired up
* (see {@link VideoPlayback}).
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> { public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) { public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op — context retained for future texture/font lookups // no-op
} }
@Override @Override
@@ -39,16 +42,57 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
public void extractRenderState(VideoAnchorBlockEntity be, State state, float partialTick, public void extractRenderState(VideoAnchorBlockEntity be, State state, float partialTick,
Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumbling) { Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumbling) {
BlockEntityRenderState.extractBase(be, state, crumbling); BlockEntityRenderState.extractBase(be, state, crumbling);
state.url = be.getUrl();
state.width = be.getWidth(); state.width = be.getWidth();
state.height = be.getHeight(); state.height = be.getHeight();
// kick playback bookkeeping so it tracks visible anchors Direction facing = be.getFacing();
VideoPlayback.getOrStart(be); state.yaw = facing.getAxis().isHorizontal() ? facing.toYRot() : 0F;
state.textureId = VideoPlayback.getOrStart(be);
} }
@Override @Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
// stub: no quad is drawn until the new dynamic texture pipeline is wired Identifier tex = state.textureId;
if (tex == null) return; // url empty or autoplay off — nothing to draw
final float w = state.width;
final float h = state.height;
final int light = state.lightCoords;
pose.pushPose();
// Center quad on the anchor's top face, rotated to face the configured direction.
pose.translate(0.5F, 1.01F, 0.5F);
pose.mulPose(Axis.YP.rotationDegrees(-state.yaw));
pose.translate(-w / 2.0F, 0F, 0F);
// Snapshot the matrix so the callback's matrix-aware addVertex works even though
// submitCustomGeometry hands us a fresh Pose (its `pose` parameter).
final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from the direction the anchor faces)
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
// Back face (visible from behind)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
});
pose.popPose();
}
private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z)
.setColor(255, 255, 255, 255)
.setUv(u, v)
.setOverlay(net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY)
.setLight(light)
.setNormal(0F, 0F, 1F);
} }
@Override @Override
@@ -61,10 +105,11 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
return 128; return 128;
} }
/** Per-frame render data extracted from the BE. Just metadata for the stub. */ /** Per-frame render data extracted from the BE. */
public static final class State extends BlockEntityRenderState { public static final class State extends BlockEntityRenderState {
public String url = ""; public Identifier textureId;
public int width = 1; public int width = 1;
public int height = 1; public int height = 1;
public float yaw = 0F;
} }
} }