2 Commits

Author SHA1 Message Date
tkrmagid
459b3249a4 fix(render): anchor video to clicked block's bottom-left, EAST/WEST flush
Some checks failed
build / build (push) Has been cancelled
Three fixes for v0.4.1:

1. Video stick item rendered as missing-texture because 26.1.2 requires the
   new client_item descriptor at assets/<mod>/items/<name>.json. Add it; the
   existing models/item/video_stick.json is kept as the underlying model.

2. Quad placement now anchors the local (0,0) corner at the bottom-left of
   the wall face the player clicked, so the clicked block is the BL and the
   video grows up & right. Previously it was centered on the anchor.

3. EAST/WEST face rotations were swapped, which placed the quad on the far
   side of the air block (~1 block away from the wall) instead of flush.
   Derived the correct rotations from first principles:
     EAST = Axis.YP +90°  (local +Z → world +X, +X → -Z = north)
     WEST = Axis.YP -90°  (local +Z → world -X, +X → +Z = south)
   NORTH/SOUTH/UP/DOWN math re-verified — those were already correct.
2026-05-15 20:21:19 +09:00
tkrmagid
2b50f56980 render: paint video on the clicked wall face (no visible anchor block)
Some checks failed
build / build (push) Has been cancelled
The anchor block becomes invisible and non-collidable; it exists only as a
BlockEntity host in the air block adjacent to the clicked wall. The renderer
now translates and rotates the textured quad so it sits flush against the
surface of the wall the user actually clicked, on any of the six faces.

Stick interaction:
  right-click face → place anchor at hit.relative(face), facing=face, open GUI
  right-click face with anchor already there → reopen the GUI
  sneak + left-click face with stick → delete the anchor on that face
The anchor's selection outline / collision / occlusion are all empty, so the
player can target the wall block behind it without interference.

JavaCV / streaming polish:
- Bump missing-JavaCV log to WARN so users notice when the runtime jar is
  not installed (previously buried at INFO).
- Add HTTP resilience options: `timeout`, `reconnect`, `reconnect_streamed`,
  `reconnect_at_eof`, and a `user_agent` so picky servers don't 403 us.
2026-05-15 20:08:33 +09:00
9 changed files with 143 additions and 54 deletions

View File

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

View File

@@ -33,9 +33,20 @@ public class VideoPlayerClient implements ClientModInitializer {
); );
AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> { AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (level.isClientSide() if (!level.isClientSide()) return InteractionResult.PASS;
&& player.getMainHandItem().getItem() instanceof VideoStickItem if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
&& level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) { // The anchor itself is invisible / non-collidable so the player cannot left-click it
// directly. Sneak + left-click on the wall the video sits on → delete the anchor in
// the adjacent air block.
if (player.isShiftKeyDown()) {
BlockPos anchorPos = pos.relative(direction);
if (level.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(anchorPos));
return InteractionResult.SUCCESS;
}
}
// Legacy / safety: if the player somehow targets the anchor block directly.
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos)); ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }

View File

@@ -10,13 +10,26 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock; import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
/**
* Anchor block — invisible, non-collidable host for {@link VideoAnchorBlockEntity}.
*
* <p>The block exists only so a {@link BlockEntity} can be attached to a position; visually it is
* completely empty (no model, no selection outline, no collision). The video itself is drawn by
* {@link com.ejclaw.videoplayer.client.render.VideoAnchorRenderer} flush against the wall the
* player clicked, not as a textured surface on this block.
*/
public class VideoAnchorBlock extends BaseEntityBlock { public class VideoAnchorBlock extends BaseEntityBlock {
public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new); public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new);
@@ -34,6 +47,31 @@ public class VideoAnchorBlock extends BaseEntityBlock {
return new VideoAnchorBlockEntity(pos, state); return new VideoAnchorBlockEntity(pos, state);
} }
@Override
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.INVISIBLE;
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getCollisionShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getOcclusionShape(BlockState state) {
return Shapes.empty();
}
@Override
protected boolean propagatesSkylightDown(BlockState state) {
return true;
}
@Override @Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
BlockPos pos, Player player, InteractionHand hand, BlockPos pos, Player player, InteractionHand hand,

View File

@@ -108,9 +108,14 @@ public class JavaCvBackend implements VideoBackend {
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class); Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class); Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
// mp4/http(s) network tuning // HTTP(S) tuning for streaming URLs (e.g. webm via Range / chunked transfer).
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {} try { setOpt.invoke(grabber, "rw_timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {} try { setOpt.invoke(grabber, "timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_streamed", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_at_eof", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "user_agent",
"video_player/" + com.ejclaw.videoplayer.VideoPlayerMod.MOD_ID); } catch (Throwable ignored) {}
// Force interleaved signed 16-bit PCM so the audio sink path is single-shape. // Force interleaved signed 16-bit PCM so the audio sink path is single-shape.
try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {} try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {}
@@ -162,7 +167,10 @@ public class JavaCvBackend implements VideoBackend {
if (audioLine == null) Thread.sleep(15); if (audioLine == null) Thread.sleep(15);
} }
} catch (ClassNotFoundException cnf) { } catch (ClassNotFoundException cnf) {
VideoPlayerMod.LOG.info("[{}] JavaCV not on classpath; backend inactive", VideoPlayerMod.MOD_ID); VideoPlayerMod.LOG.warn(
"[{}] JavaCV not on classpath — install org.bytedeco:javacv-platform (or javacv + ffmpeg natives)" +
" to enable video/audio playback. Anchor placeholder will remain visible.",
VideoPlayerMod.MOD_ID);
} catch (InterruptedException ie) { } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (Throwable t) { } catch (Throwable t) {

View File

@@ -20,15 +20,19 @@ import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f; import org.joml.Matrix4f;
/** /**
* SPEC §5.2 — submits a width×height textured quad in front of the anchor, oriented by facing. * Draws the video as a textured quad <em>on the surface of the block the user clicked</em>.
* *
* <p>Ported to 26.1.2's render-state pipeline: per-frame BE state is captured in * <p>The anchor BE lives in the air block adjacent to the clicked wall. Its {@code facing}
* {@link State} via {@link #extractRenderState}, then drawn via * field is the surface normal of the wall (= the {@link Direction} the player clicked). The
* {@link SubmitNodeCollector#submitCustomGeometry} during {@link #submit}. * quad is rotated so its normal aligns with that direction and shifted so it sits flush against
* the wall surface, with a tiny outward offset to avoid z-fighting.
*/ */
@Environment(EnvType.CLIENT) @Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> { 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;
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) { public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op // no-op
} }
@@ -44,8 +48,7 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
BlockEntityRenderState.extractBase(be, state, crumbling); BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth(); state.width = be.getWidth();
state.height = be.getHeight(); state.height = be.getHeight();
Direction facing = be.getFacing(); state.facing = be.getFacing();
state.yaw = facing.getAxis().isHorizontal() ? facing.toYRot() : 0F;
state.textureId = VideoPlayback.getOrStart(be); state.textureId = VideoPlayback.getOrStart(be);
} }
@@ -57,25 +60,28 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
final float w = state.width; final float w = state.width;
final float h = state.height; final float h = state.height;
final int light = state.lightCoords; final int light = state.lightCoords;
final Direction f = state.facing == null ? Direction.NORTH : state.facing;
pose.pushPose(); pose.pushPose();
// Center quad on the anchor's top face, rotated to face the configured direction. // 1) Move to the anchor block's center.
pose.translate(0.5F, 1.01F, 0.5F); pose.translate(0.5F, 0.5F, 0.5F);
pose.mulPose(Axis.YP.rotationDegrees(-state.yaw)); // 2) Rotate local +Z to align with the wall's outward normal.
pose.translate(-w / 2.0F, 0F, 0F); applyFaceRotation(pose, f);
// 3) Place the quad's local origin (0,0) at the bottom-left corner of the anchor block's
// wall face, so the clicked block becomes the lower-left and the video grows up & right.
// Push it onto the wall surface (-0.5 along local +Z, the outward normal) plus a tiny
// epsilon outward so the quad doesn't z-fight with the wall.
pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON);
// 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()); final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex); RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> { collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from the direction the anchor faces) // Front face (visible from outside, looking back at the wall)
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light); emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light); emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light); emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light); emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
// Back face (visible from behind) // Back face (in case the player ends up on the other side, e.g. clipping into the wall)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light); emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light); emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light); emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
@@ -85,6 +91,24 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
pose.popPose(); pose.popPose();
} }
/**
* Rotate so local +Z (the quad's outward normal in its base orientation) becomes world {@code f},
* with local +X mapped to the natural "right" direction the player sees when looking at the face.
* Derivation: for each face {@code f}, pick the rotation that maps local +Z → f, +Y → world up
* (or a sensible substitute for top/bottom), so the quad lies flush against the wall, oriented
* the way the player intuits.
*/
private static void applyFaceRotation(PoseStack pose, Direction f) {
switch (f) {
case SOUTH -> { /* identity: local +Z = world +Z (south). +X = east, +Y = up. */ }
case NORTH -> pose.mulPose(Axis.YP.rotationDegrees(180F)); // +Z → -Z, +X → -X (west)
case EAST -> pose.mulPose(Axis.YP.rotationDegrees(90F)); // +Z → +X, +X → -Z (north)
case WEST -> pose.mulPose(Axis.YP.rotationDegrees(-90F)); // +Z → -X, +X → +Z (south)
case UP -> pose.mulPose(Axis.XP.rotationDegrees(-90F)); // +Z → +Y, +Y → -Z (north)
case DOWN -> pose.mulPose(Axis.XP.rotationDegrees(90F)); // +Z → -Y, +Y → +Z (south)
}
}
private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat, private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v, int light) { float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z) vc.addVertex(mat, x, y, z)
@@ -110,6 +134,6 @@ public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlock
public Identifier textureId; public Identifier textureId;
public int width = 1; public int width = 1;
public int height = 1; public int height = 1;
public float yaw = 0F; public Direction facing = Direction.NORTH;
} }
} }

View File

@@ -16,7 +16,18 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
/** Right-click empty face → place anchor + open GUI. Right-click existing anchor → edit. */ /**
* Right-click a block's face with the video stick:
* <ul>
* <li>If a video anchor already exists in the adjacent air (= an anchor already drawn on this
* face), reopen its config GUI.</li>
* <li>Otherwise place an invisible anchor in the adjacent air block, set its facing to the
* clicked face direction (so the renderer draws the quad flush against this face), and
* open the config GUI.</li>
* </ul>
* The anchor block itself is invisible / non-collidable, so visually no new block appears —
* the video just shows up on the face the user clicked.
*/
public class VideoStickItem extends Item { public class VideoStickItem extends Item {
public VideoStickItem(Properties properties) { public VideoStickItem(Properties properties) {
super(properties); super(properties);
@@ -33,25 +44,27 @@ public class VideoStickItem extends Item {
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS; if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos(); BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor → edit // Existing anchor on this face → reopen edit GUI.
if (sl.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) { if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt())); ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, existing.toNbt()));
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }
// Empty face → place anchor on top of the clicked face // Need an empty / replaceable space in front of the clicked face.
Direction side = ctx.getClickedFace(); BlockState there = sl.getBlockState(anchorPos);
BlockPos placeAt = hit.relative(side);
BlockState there = sl.getBlockState(placeAt);
if (!there.canBeReplaced()) return InteractionResult.PASS; if (!there.canBeReplaced()) return InteractionResult.PASS;
Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR; Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR;
sl.setBlock(placeAt, anchor.defaultBlockState(), Block.UPDATE_ALL); sl.setBlock(anchorPos, anchor.defaultBlockState(), Block.UPDATE_ALL);
if (sl.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) { if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
be.setFacing(ctx.getHorizontalDirection().getOpposite()); // Surface normal of the wall we're painting on points outward in the same direction
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt())); // as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
} }
return InteractionResult.SUCCESS; return InteractionResult.SUCCESS;
} }

View File

@@ -14,7 +14,12 @@ public final class VideoPlayerBlocks {
public static final Block VIDEO_ANCHOR = register( public static final Block VIDEO_ANCHOR = register(
"video_anchor", "video_anchor",
BlockBehaviour.Properties.of().strength(1.0F).noOcclusion(), BlockBehaviour.Properties.of()
.noCollision()
.noOcclusion()
.instabreak()
.replaceable()
.strength(0F),
VideoAnchorBlock::new VideoAnchorBlock::new
); );

View File

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "video_player:item/video_stick"
}
}

View File

@@ -1,21 +1,5 @@
{ {
"parent": "block/block",
"textures": { "textures": {
"all": "video_player:block/video_anchor",
"particle": "video_player:block/video_anchor" "particle": "video_player:block/video_anchor"
}, }
"elements": [
{
"from": [0, 0, 0],
"to": [16, 2, 16],
"faces": {
"down": { "texture": "#all", "uv": [0, 0, 16, 16] },
"up": { "texture": "#all", "uv": [0, 0, 16, 16] },
"north": { "texture": "#all", "uv": [0, 0, 16, 2] },
"south": { "texture": "#all", "uv": [0, 0, 16, 2] },
"east": { "texture": "#all", "uv": [0, 0, 16, 2] },
"west": { "texture": "#all", "uv": [0, 0, 16, 2] }
}
}
]
} }