Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,194 @@
package com.tiedup.remake.client.renderer;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat;
import com.tiedup.remake.blocks.entity.CellCoreBlockEntity;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderStateShard;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.SlabBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.SlabType;
import org.joml.Matrix4f;
/**
* Block entity renderer for CellCore.
*
* Renders a small pulsing cyan diamond indicator on the interior face
* of the Cell Core block. This helps players identify which side of the
* block faces into the cell, and confirms the block is a Cell Core.
*
* Extends RenderStateShard to access protected render state fields
* (standard Forge pattern for custom RenderTypes).
*/
public class CellCoreRenderer
extends RenderStateShard
implements BlockEntityRenderer<CellCoreBlockEntity>
{
private static final float DIAMOND_SIZE = 0.15f;
private static final float FACE_OFFSET = 0.001f;
// Cyan indicator color (#44FFFF)
private static final float COLOR_R = 0.267f;
private static final float COLOR_G = 1.0f;
private static final float COLOR_B = 1.0f;
/**
* Custom RenderType: POSITION_COLOR quads with translucent blending, no texture.
* Avoids block atlas UV issues that made the diamond invisible.
*/
private static final RenderType INDICATOR = RenderType.create(
"tiedup_indicator",
DefaultVertexFormat.POSITION_COLOR,
VertexFormat.Mode.QUADS,
256,
false,
true,
RenderType.CompositeState.builder()
.setShaderState(
new ShaderStateShard(GameRenderer::getPositionColorShader)
)
.setTransparencyState(TRANSLUCENT_TRANSPARENCY)
.setCullState(NO_CULL)
.setDepthTestState(LEQUAL_DEPTH_TEST)
.setWriteMaskState(COLOR_DEPTH_WRITE)
.createCompositeState(false)
);
public CellCoreRenderer(BlockEntityRendererProvider.Context context) {
super("tiedup_cell_core_renderer", () -> {}, () -> {});
}
@Override
public void render(
CellCoreBlockEntity blockEntity,
float partialTick,
PoseStack poseStack,
MultiBufferSource bufferSource,
int packedLight,
int packedOverlay
) {
Direction interiorFace = blockEntity.getInteriorFace();
if (interiorFace == null) return;
if (blockEntity.getLevel() == null) return;
// Calculate pulsing alpha synced to game time (pauses when game pauses)
float time = blockEntity.getLevel().getGameTime() + partialTick;
float alpha = 0.4f + 0.2f * (float) Math.sin(time * 0.15);
// Compute vertical center: adapt to slab shape if disguised as a slab
float centerY = 0.5f;
BlockState disguise = blockEntity.getDisguiseState();
if (disguise == null && blockEntity.getLevel() != null) {
// Auto-detect: check resolved model data (same logic as CellCoreBlockEntity)
disguise = blockEntity
.getModelData()
.get(
com.tiedup.remake.client.model.CellCoreBakedModel.DISGUISE_PROPERTY
);
}
if (disguise != null && disguise.getBlock() instanceof SlabBlock) {
SlabType slabType = disguise.getValue(SlabBlock.TYPE);
if (slabType == SlabType.BOTTOM) {
centerY = 0.25f; // lower half
} else if (slabType == SlabType.TOP) {
centerY = 0.75f; // upper half
}
// DOUBLE = 0.5f (full block)
}
poseStack.pushPose();
VertexConsumer consumer = bufferSource.getBuffer(INDICATOR);
Matrix4f pose = poseStack.last().pose();
renderDiamond(consumer, pose, interiorFace, alpha, centerY);
poseStack.popPose();
}
private void renderDiamond(
VertexConsumer consumer,
Matrix4f pose,
Direction face,
float alpha,
float centerY
) {
float[][] verts = getDiamondVertices(face, 0.5f, centerY, 0.5f);
for (float[] v : verts) {
consumer
.vertex(pose, v[0], v[1], v[2])
.color(COLOR_R, COLOR_G, COLOR_B, alpha)
.endVertex();
}
}
/**
* Get the 4 vertices of a diamond shape on the given face.
* The diamond is centered on the face and offset slightly outward to avoid z-fighting.
*/
private float[][] getDiamondVertices(
Direction face,
float cx,
float cy,
float cz
) {
float s = DIAMOND_SIZE;
float o = FACE_OFFSET;
return switch (face) {
case NORTH -> new float[][] {
{ cx, cy + s, 0.0f - o }, // top
{ cx + s, cy, 0.0f - o }, // right
{ cx, cy - s, 0.0f - o }, // bottom
{ cx - s, cy, 0.0f - o }, // left
};
case SOUTH -> new float[][] {
{ cx, cy + s, 1.0f + o },
{ cx - s, cy, 1.0f + o },
{ cx, cy - s, 1.0f + o },
{ cx + s, cy, 1.0f + o },
};
case WEST -> new float[][] {
{ 0.0f - o, cy + s, cz },
{ 0.0f - o, cy, cz - s },
{ 0.0f - o, cy - s, cz },
{ 0.0f - o, cy, cz + s },
};
case EAST -> new float[][] {
{ 1.0f + o, cy + s, cz },
{ 1.0f + o, cy, cz + s },
{ 1.0f + o, cy - s, cz },
{ 1.0f + o, cy, cz - s },
};
case DOWN -> {
// Bottom face: y=0.0 for full blocks & bottom slabs, y=0.5 for top slabs only
float downY = (cy >= 0.75f) ? 0.5f - o : 0.0f - o;
yield new float[][] {
{ cx, downY, cz + s },
{ cx + s, downY, cz },
{ cx, downY, cz - s },
{ cx - s, downY, cz },
};
}
case UP -> {
// Top face: y=1.0 for full blocks & top slabs, y=0.5 for bottom slabs only
float upY = (cy <= 0.25f) ? 0.5f + o : 1.0f + o;
yield new float[][] {
{ cx, upY, cz - s },
{ cx + s, upY, cz },
{ cx, upY, cz + s },
{ cx - s, upY, cz },
};
}
};
}
}

View File

@@ -0,0 +1,361 @@
package com.tiedup.remake.client.renderer;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.*;
import com.tiedup.remake.cells.CellDataV2;
import com.tiedup.remake.cells.MarkerType;
import java.util.Collection;
import java.util.Set;
import net.minecraft.client.Camera;
import net.minecraft.client.renderer.GameRenderer;
import net.minecraft.core.BlockPos;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
/**
* Utility class for rendering cell outlines in the world.
*
* Simplified renderer - only filled blocks, no wireframe outlines.
* Each marker type gets a semi-transparent colored block overlay.
* Spawn point has a pulsating effect.
*
* Features:
* - Depth test enabled (blocks hidden behind world geometry)
* - Simple filled block rendering per marker type
* - Pulsating spawn point indicator
*/
public class CellOutlineRenderer {
/** Enable depth test (true = blocks hidden behind world blocks) */
public static boolean DEPTH_TEST_ENABLED = true;
// Simple colors per type (RGBA)
private static final float[] COLOR_WALL = { 0.3f, 0.5f, 1.0f, 1.0f }; // Blue
private static final float[] COLOR_ANCHOR = { 1.0f, 0.2f, 0.2f, 1.0f }; // Red
private static final float[] COLOR_BED = { 0.9f, 0.4f, 0.9f, 1.0f }; // Violet
private static final float[] COLOR_DOOR = { 0.2f, 0.9f, 0.9f, 1.0f }; // Cyan
private static final float[] COLOR_ENTRANCE = { 0.2f, 1.0f, 0.4f, 1.0f }; // Green
private static final float[] COLOR_PATROL = { 1.0f, 1.0f, 0.2f, 1.0f }; // Yellow
private static final float[] COLOR_LOOT = { 1.0f, 0.7f, 0.0f, 1.0f }; // Gold
private static final float[] COLOR_SPAWNER = { 0.8f, 0.1f, 0.1f, 1.0f }; // Dark red
private static final float[] COLOR_TRADER_SPAWN = {
1.0f,
0.84f,
0.0f,
1.0f,
}; // Gold
private static final float[] COLOR_MAID_SPAWN = {
1.0f,
0.41f,
0.71f,
1.0f,
}; // Hot pink
private static final float[] COLOR_MERCHANT_SPAWN = {
0.2f,
0.9f,
0.9f,
1.0f,
}; // Cyan
private static final float[] COLOR_DELIVERY = { 1.0f, 0.8f, 0.2f, 1.0f }; // Orange/Yellow
private static final float[] COLOR_SPAWN = { 1.0f, 0.0f, 1.0f, 1.0f }; // Magenta
private static final float[] COLOR_WAYPOINT = { 0.0f, 1.0f, 0.5f, 1.0f }; // Bright green
/**
* Render filled blocks for all positions in a cell.
* Renders V2 cell data: walls, anchors, beds, doors, and spawn/core positions.
*
* @param poseStack The pose stack from the render event
* @param cell The cell data to render
* @param camera The camera for view offset calculation
*/
public static void renderCellOutlines(
PoseStack poseStack,
CellDataV2 cell,
Camera camera
) {
if (cell == null) return;
Vec3 cameraPos = camera.getPosition();
// Setup rendering state
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
RenderSystem.enableDepthTest();
RenderSystem.depthMask(false);
RenderSystem.setShader(GameRenderer::getPositionColorShader);
// 1. Render spawn point or core pos (pulsating magenta)
BlockPos spawnPoint =
cell.getSpawnPoint() != null
? cell.getSpawnPoint()
: cell.getCorePos();
float pulse =
0.4f + 0.2f * (float) Math.sin(System.currentTimeMillis() / 300.0);
float[] spawnColor = {
COLOR_SPAWN[0],
COLOR_SPAWN[1],
COLOR_SPAWN[2],
pulse,
};
renderFilledBlock(poseStack, spawnPoint, cameraPos, spawnColor);
// 2. Render wall blocks
renderPositionCollection(
poseStack,
cell.getWallBlocks(),
cameraPos,
COLOR_WALL
);
// 3. Render anchors
renderPositionCollection(
poseStack,
cell.getAnchors(),
cameraPos,
COLOR_ANCHOR
);
// 4. Render beds
renderPositionCollection(
poseStack,
cell.getBeds(),
cameraPos,
COLOR_BED
);
// 5. Render doors
renderPositionCollection(
poseStack,
cell.getDoors(),
cameraPos,
COLOR_DOOR
);
// 6. Render delivery point if present
BlockPos deliveryPoint = cell.getDeliveryPoint();
if (deliveryPoint != null) {
float[] deliveryFillColor = {
COLOR_DELIVERY[0],
COLOR_DELIVERY[1],
COLOR_DELIVERY[2],
0.35f,
};
renderFilledBlock(
poseStack,
deliveryPoint,
cameraPos,
deliveryFillColor
);
}
// 7. Render path waypoints with pulsating effect and numbers
java.util.List<BlockPos> waypoints = cell.getPathWaypoints();
if (!waypoints.isEmpty()) {
float waypointPulse =
0.5f +
0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0);
float[] waypointColor = {
COLOR_WAYPOINT[0],
COLOR_WAYPOINT[1],
COLOR_WAYPOINT[2],
waypointPulse,
};
for (int i = 0; i < waypoints.size(); i++) {
BlockPos wp = waypoints.get(i);
renderFilledBlock(poseStack, wp, cameraPos, waypointColor);
// Render number above waypoint
renderWaypointNumber(poseStack, wp, cameraPos, i + 1);
}
}
// Restore rendering state
RenderSystem.depthMask(true);
RenderSystem.disableBlend();
}
/**
* Render a collection of positions with the given base color at 0.35 alpha.
*/
private static void renderPositionCollection(
PoseStack poseStack,
Collection<BlockPos> positions,
Vec3 cameraPos,
float[] baseColor
) {
if (positions == null || positions.isEmpty()) return;
float[] fillColor = { baseColor[0], baseColor[1], baseColor[2], 0.35f };
for (BlockPos pos : positions) {
renderFilledBlock(poseStack, pos, cameraPos, fillColor);
}
}
/**
* Render a filled block (semi-transparent cube).
*/
public static void renderFilledBlock(
PoseStack poseStack,
BlockPos pos,
Vec3 cameraPos,
float[] color
) {
poseStack.pushPose();
double x = pos.getX() - cameraPos.x;
double y = pos.getY() - cameraPos.y;
double z = pos.getZ() - cameraPos.z;
poseStack.translate(x, y, z);
Matrix4f matrix = poseStack.last().pose();
Tesselator tesselator = Tesselator.getInstance();
BufferBuilder buffer = tesselator.getBuilder();
buffer.begin(
VertexFormat.Mode.QUADS,
DefaultVertexFormat.POSITION_COLOR
);
float r = color[0];
float g = color[1];
float b = color[2];
float a = color[3];
float min = 0.0f;
float max = 1.0f;
// Bottom face
vertex(buffer, matrix, min, min, min, r, g, b, a);
vertex(buffer, matrix, max, min, min, r, g, b, a);
vertex(buffer, matrix, max, min, max, r, g, b, a);
vertex(buffer, matrix, min, min, max, r, g, b, a);
// Top face
vertex(buffer, matrix, min, max, min, r, g, b, a);
vertex(buffer, matrix, min, max, max, r, g, b, a);
vertex(buffer, matrix, max, max, max, r, g, b, a);
vertex(buffer, matrix, max, max, min, r, g, b, a);
// North face
vertex(buffer, matrix, min, min, min, r, g, b, a);
vertex(buffer, matrix, min, max, min, r, g, b, a);
vertex(buffer, matrix, max, max, min, r, g, b, a);
vertex(buffer, matrix, max, min, min, r, g, b, a);
// South face
vertex(buffer, matrix, min, min, max, r, g, b, a);
vertex(buffer, matrix, max, min, max, r, g, b, a);
vertex(buffer, matrix, max, max, max, r, g, b, a);
vertex(buffer, matrix, min, max, max, r, g, b, a);
// West face
vertex(buffer, matrix, min, min, min, r, g, b, a);
vertex(buffer, matrix, min, min, max, r, g, b, a);
vertex(buffer, matrix, min, max, max, r, g, b, a);
vertex(buffer, matrix, min, max, min, r, g, b, a);
// East face
vertex(buffer, matrix, max, min, min, r, g, b, a);
vertex(buffer, matrix, max, max, min, r, g, b, a);
vertex(buffer, matrix, max, max, max, r, g, b, a);
vertex(buffer, matrix, max, min, max, r, g, b, a);
tesselator.end();
poseStack.popPose();
}
private static void vertex(
BufferBuilder buffer,
Matrix4f matrix,
float x,
float y,
float z,
float r,
float g,
float b,
float a
) {
buffer.vertex(matrix, x, y, z).color(r, g, b, a).endVertex();
}
/**
* Render a waypoint number floating above the block.
*/
public static void renderWaypointNumber(
PoseStack poseStack,
BlockPos pos,
Vec3 cameraPos,
int number
) {
poseStack.pushPose();
double x = pos.getX() + 0.5 - cameraPos.x;
double y = pos.getY() + 1.5 - cameraPos.y;
double z = pos.getZ() + 0.5 - cameraPos.z;
poseStack.translate(x, y, z);
// Billboard effect - face the camera
net.minecraft.client.Minecraft mc =
net.minecraft.client.Minecraft.getInstance();
poseStack.mulPose(mc.getEntityRenderDispatcher().cameraOrientation());
poseStack.scale(-0.05f, -0.05f, 0.05f);
// Render the number
String text = String.valueOf(number);
net.minecraft.client.gui.Font font = mc.font;
float textWidth = font.width(text);
// Background
net.minecraft.client.renderer.MultiBufferSource.BufferSource buffer = mc
.renderBuffers()
.bufferSource();
font.drawInBatch(
text,
-textWidth / 2,
0,
0x00FF80, // Bright green
false,
poseStack.last().pose(),
buffer,
net.minecraft.client.gui.Font.DisplayMode.NORMAL,
0x80000000, // Semi-transparent black background
15728880
);
buffer.endBatch();
poseStack.popPose();
}
/**
* Get the color for a marker type.
*/
public static float[] getColorForType(MarkerType type) {
return switch (type) {
case WALL -> COLOR_WALL;
case ANCHOR -> COLOR_ANCHOR;
case BED -> COLOR_BED;
case DOOR -> COLOR_DOOR;
case DELIVERY -> COLOR_DELIVERY;
case ENTRANCE -> COLOR_ENTRANCE;
case PATROL -> COLOR_PATROL;
case LOOT -> COLOR_LOOT;
case SPAWNER -> COLOR_SPAWNER;
case TRADER_SPAWN -> COLOR_TRADER_SPAWN;
case MAID_SPAWN -> COLOR_MAID_SPAWN;
case MERCHANT_SPAWN -> COLOR_MERCHANT_SPAWN;
};
}
/**
* Get the spawn point color.
*/
public static float[] getSpawnColor() {
return COLOR_SPAWN;
}
}

View File

@@ -0,0 +1,224 @@
package com.tiedup.remake.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import com.tiedup.remake.client.animation.render.RenderConstants;
import com.tiedup.remake.client.model.DamselModel;
import com.tiedup.remake.compat.wildfire.WildfireCompat;
import com.tiedup.remake.compat.wildfire.render.WildfireDamselLayer;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import net.minecraft.client.model.HumanoidArmorModel;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.HumanoidMobRenderer;
import net.minecraft.client.renderer.entity.layers.HumanoidArmorLayer;
import net.minecraft.client.renderer.entity.layers.ItemInHandLayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Renderer for AbstractTiedUpNpc and all subtypes (Kidnapper, Elite, Archer, Merchant, Shiny).
*
* <p>Uses ISkinnedEntity interface for polymorphic texture lookup.
* Each entity subclass overrides getSkinTexture() to return the appropriate texture.
*
* <p><b>Issue #19 fix:</b> Replaced 6+ instanceof checks with single interface call.
*/
@OnlyIn(Dist.CLIENT)
public class DamselRenderer
extends HumanoidMobRenderer<AbstractTiedUpNpc, DamselModel>
{
/**
* Normal arms model (4px wide - Steve model).
*/
private final DamselModel normalModel;
/**
* Slim arms model (3px wide - Alex model).
*/
private final DamselModel slimModel;
/**
* Create renderer.
*
* Phase 19: Uses vanilla ModelLayers.PLAYER for full layer support (jacket, sleeves, pants).
*/
public DamselRenderer(EntityRendererProvider.Context context) {
super(
context,
new DamselModel(context.bakeLayer(ModelLayers.PLAYER), false),
0.5f // Shadow radius
);
// Store both models for runtime swapping
this.normalModel = this.getModel();
this.slimModel = new DamselModel(
context.bakeLayer(ModelLayers.PLAYER_SLIM),
true
);
// Add armor render layer (renders equipped armor)
this.addLayer(
new HumanoidArmorLayer<>(
this,
new HumanoidArmorModel<>(
context.bakeLayer(ModelLayers.PLAYER_INNER_ARMOR)
),
new HumanoidArmorModel<>(
context.bakeLayer(ModelLayers.PLAYER_OUTER_ARMOR)
),
context.getModelManager()
)
);
// Add item in hand layer (renders held items)
this.addLayer(
new ItemInHandLayer<>(this, context.getItemInHandRenderer())
);
// Add Wildfire breast layer BEFORE bondage (so bondage renders on top of breasts)
if (WildfireCompat.isLoaded()) {
this.addLayer(
new WildfireDamselLayer<>(this, context.getModelSet())
);
}
// Add V2 bondage render layer (GLB-based V2 equipment rendering)
this.addLayer(new com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer<>(this));
}
/**
* Render the entity.
* Uses entity's hasSlimArms() for model selection.
*
* Phase 19: Wearer layer hiding is now handled in DamselModel.setupAnim()
* to ensure it happens after visibility resets.
*
* DOG pose: X rotation is applied in setupRotations() AFTER Y rotation,
* so the "belly down" direction follows entity facing.
* Head compensation is applied in DamselModel.setupAnim().
* Body rotation smoothing is handled in AbstractTiedUpNpc.tick().
*/
@Override
public void render(
AbstractTiedUpNpc entity,
float entityYaw,
float partialTicks,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight
) {
// Use entity's hasSlimArms() - each entity type overrides this appropriately
boolean useSlim = entity.hasSlimArms();
// Swap to appropriate model
this.model = useSlim ? this.slimModel : this.normalModel;
// Apply vertical offset for sitting/kneeling/dog poses
// This ensures the model AND all layers (gag, blindfold, etc.) move together
float verticalOffset = getVerticalOffset(entity);
boolean pushedPose = false;
if (verticalOffset != 0) {
poseStack.pushPose();
pushedPose = true;
// Convert from model units (16 = 1 block) to render units
poseStack.translate(0, verticalOffset / 16.0, 0);
}
// Call parent render
// Note: Wearer layer hiding happens in DamselModel.setupAnim()
super.render(
entity,
entityYaw,
partialTicks,
poseStack,
buffer,
packedLight
);
if (pushedPose) {
poseStack.popPose();
}
}
/**
* Get vertical offset for sitting/kneeling/dog poses.
* Returns offset in model units (16 units = 1 block).
*
* @param entity The entity to check
* @return Vertical offset (negative = down)
*/
private float getVerticalOffset(AbstractTiedUpNpc entity) {
if (entity.isSitting()) {
return RenderConstants.DAMSEL_SIT_OFFSET;
} else if (entity.isKneeling()) {
return RenderConstants.DAMSEL_KNEEL_OFFSET;
} else if (entity.isDogPose()) {
return RenderConstants.DAMSEL_DOG_OFFSET;
}
return 0;
}
/**
* Get the texture location based on entity type.
*
* <p>Issue #19 fix: Uses ISkinnedEntity interface instead of instanceof cascade.
* Each entity subclass implements getSkinTexture() to return appropriate texture.
*/
@Override
public ResourceLocation getTextureLocation(AbstractTiedUpNpc entity) {
// ISkinnedEntity provides polymorphic skin texture lookup
// Each entity type (Damsel, Kidnapper, Elite, Archer, Merchant, Shiny)
// overrides getSkinTexture() to return the correct texture
return entity.getSkinTexture();
}
/**
* Apply scale transformation.
*/
@Override
protected void scale(
AbstractTiedUpNpc entity,
PoseStack poseStack,
float partialTick
) {
poseStack.scale(0.9375f, 0.9375f, 0.9375f);
}
/**
* Setup rotations for the entity.
*
* DOG pose: After Y rotation is applied by parent, add X rotation
* to make the body horizontal. This is applied in entity-local space,
* so the "belly down" direction follows the entity's facing.
*/
@Override
protected void setupRotations(
AbstractTiedUpNpc entity,
PoseStack poseStack,
float ageInTicks,
float rotationYaw,
float partialTicks
) {
// Call parent to apply Y rotation (body facing)
super.setupRotations(
entity,
poseStack,
ageInTicks,
rotationYaw,
partialTicks
);
// DOG pose: Apply X rotation to make body horizontal
// This happens AFTER Y rotation, so it's in entity-local space
if (entity.isDogPose()) {
// Rotate -90° on X axis around the model's pivot point
// Pivot at waist height (12 model units = 0.75 blocks up from feet)
poseStack.translate(0, 0.75, 0);
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
poseStack.translate(0, -0.75, 0);
}
}
}

View File

@@ -0,0 +1,85 @@
package com.tiedup.remake.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import com.tiedup.remake.blocks.ModBlocks;
import com.tiedup.remake.entities.EntityKidnapBomb;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.block.BlockRenderDispatcher;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.TntMinecartRenderer;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.Mth;
/**
* Renderer for EntityKidnapBomb.
*
* Phase 16: Blocks
*
* Renders the primed kidnap bomb using our custom block texture.
*/
public class KidnapBombRenderer extends EntityRenderer<EntityKidnapBomb> {
private final BlockRenderDispatcher blockRenderer;
public KidnapBombRenderer(EntityRendererProvider.Context context) {
super(context);
this.shadowRadius = 0.5F;
this.blockRenderer = context.getBlockRenderDispatcher();
}
@Override
public void render(
EntityKidnapBomb entity,
float entityYaw,
float partialTicks,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight
) {
poseStack.pushPose();
poseStack.translate(0.0F, 0.5F, 0.0F);
int fuse = entity.getFuse();
if ((float) fuse - partialTicks + 1.0F < 10.0F) {
float scale = 1.0F - ((float) fuse - partialTicks + 1.0F) / 10.0F;
scale = Mth.clamp(scale, 0.0F, 1.0F);
scale *= scale;
scale *= scale;
float expand = 1.0F + scale * 0.3F;
poseStack.scale(expand, expand, expand);
}
poseStack.mulPose(Axis.YP.rotationDegrees(-90.0F));
poseStack.translate(-0.5F, -0.5F, 0.5F);
poseStack.mulPose(Axis.YP.rotationDegrees(90.0F));
// Render our custom block instead of vanilla TNT
TntMinecartRenderer.renderWhiteSolidBlock(
this.blockRenderer,
ModBlocks.KIDNAP_BOMB.get().defaultBlockState(),
poseStack,
buffer,
packedLight,
(fuse / 5) % 2 == 0
);
poseStack.popPose();
super.render(
entity,
entityYaw,
partialTicks,
poseStack,
buffer,
packedLight
);
}
@Override
@SuppressWarnings("deprecation")
public ResourceLocation getTextureLocation(EntityKidnapBomb entity) {
return TextureAtlas.LOCATION_BLOCKS;
}
}

View File

@@ -0,0 +1,94 @@
package com.tiedup.remake.client.renderer;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import com.tiedup.remake.entities.NpcFishingBobber;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.ResourceLocation;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
/**
* Renderer for NpcFishingBobber.
*
* Renders a textured quad using the vanilla fishing hook texture.
* Billboard-style: always faces the camera.
*/
public class NpcFishingBobberRenderer extends EntityRenderer<NpcFishingBobber> {
private static final ResourceLocation TEXTURE = new ResourceLocation(
"textures/entity/fishing_hook.png"
);
private static final RenderType RENDER_TYPE = RenderType.entityCutout(
TEXTURE
);
public NpcFishingBobberRenderer(EntityRendererProvider.Context ctx) {
super(ctx);
}
@Override
public void render(
NpcFishingBobber entity,
float yaw,
float partialTick,
PoseStack poseStack,
MultiBufferSource bufferSource,
int packedLight
) {
poseStack.pushPose();
poseStack.scale(0.5F, 0.5F, 0.5F);
poseStack.mulPose(this.entityRenderDispatcher.cameraOrientation());
poseStack.mulPose(Axis.YP.rotationDegrees(180.0F));
VertexConsumer vertexConsumer = bufferSource.getBuffer(RENDER_TYPE);
PoseStack.Pose pose = poseStack.last();
Matrix4f matrix4f = pose.pose();
Matrix3f matrix3f = pose.normal();
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 0, 0, 1);
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 0, 1, 1);
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 1, 1, 0);
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 1, 0, 0);
poseStack.popPose();
super.render(
entity,
yaw,
partialTick,
poseStack,
bufferSource,
packedLight
);
}
private static void vertex(
VertexConsumer consumer,
Matrix4f matrix4f,
Matrix3f matrix3f,
int packedLight,
float x,
int y,
int u,
int v
) {
consumer
.vertex(matrix4f, x - 0.5F, y - 0.5F, 0.0F)
.color(255, 255, 255, 255)
.uv((float) u, (float) v)
.overlayCoords(OverlayTexture.NO_OVERLAY)
.uv2(packedLight)
.normal(matrix3f, 0.0F, 1.0F, 0.0F)
.endVertex();
}
@Override
public ResourceLocation getTextureLocation(NpcFishingBobber entity) {
return TEXTURE;
}
}

View File

@@ -0,0 +1,32 @@
package com.tiedup.remake.client.renderer;
import com.tiedup.remake.entities.EntityRopeArrow;
import net.minecraft.client.renderer.entity.ArrowRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Renderer for EntityRopeArrow.
* Phase 15: Uses vanilla arrow texture for simplicity.
*/
@OnlyIn(Dist.CLIENT)
public class RopeArrowRenderer extends ArrowRenderer<EntityRopeArrow> {
/** Texture for the rope arrow (uses vanilla arrow texture) */
private static final ResourceLocation TEXTURE =
ResourceLocation.fromNamespaceAndPath(
"minecraft",
"textures/entity/projectiles/arrow.png"
);
public RopeArrowRenderer(EntityRendererProvider.Context context) {
super(context);
}
@Override
public ResourceLocation getTextureLocation(EntityRopeArrow entity) {
return TEXTURE;
}
}

View File

@@ -0,0 +1,80 @@
package com.tiedup.remake.client.renderer.layers;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.model.geom.EntityModelSet;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Cache for PlayerModel instances used for clothes rendering.
*
* <p>Clothes textures are standard Minecraft skins (64x64) and need to be
* rendered using PlayerModel with correct UV mappings, NOT the bondage item models.
*
* <p>Two models are cached:
* <ul>
* <li>Normal (Steve) - 4px wide arms</li>
* <li>Slim (Alex) - 3px wide arms</li>
* </ul>
*
* <p>Initialize via {@link #init(EntityModelSet)} during AddLayers event.
*/
@OnlyIn(Dist.CLIENT)
public class ClothesModelCache {
private static PlayerModel<AbstractClientPlayer> normalModel;
private static PlayerModel<AbstractClientPlayer> slimModel;
private static boolean initialized = false;
/**
* Initialize the model cache.
* Must be called during EntityRenderersEvent.AddLayers.
*
* @param modelSet The entity model set from the event
*/
public static void init(EntityModelSet modelSet) {
if (initialized) {
return;
}
normalModel = new PlayerModel<>(
modelSet.bakeLayer(ModelLayers.PLAYER),
false
);
slimModel = new PlayerModel<>(
modelSet.bakeLayer(ModelLayers.PLAYER_SLIM),
true
);
initialized = true;
TiedUpMod.LOGGER.info(
"[ClothesModelCache] Initialized normal and slim player models for clothes rendering"
);
}
/**
* Get the appropriate player model for clothes rendering.
*
* @param slim true for slim (Alex) arms, false for normal (Steve) arms
* @return The cached PlayerModel
*/
public static PlayerModel<AbstractClientPlayer> getModel(boolean slim) {
if (!initialized) {
TiedUpMod.LOGGER.warn(
"[ClothesModelCache] Not initialized! Returning null."
);
return null;
}
return slim ? slimModel : normalModel;
}
/**
* Check if the cache is initialized.
*/
public static boolean isInitialized() {
return initialized;
}
}

View File

@@ -0,0 +1,609 @@
package com.tiedup.remake.client.renderer.layers;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.tiedup.remake.client.state.ClothesClientCache;
import com.tiedup.remake.client.texture.DynamicTextureManager;
import com.tiedup.remake.items.clothes.ClothesProperties;
import com.tiedup.remake.items.clothes.GenericClothes;
import java.util.EnumSet;
import java.util.UUID;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Helper class for rendering clothes with dynamic textures.
*
* <p>IMPORTANT: Clothes are rendered using a PlayerModel (not bondage item model)
* because clothes textures are standard Minecraft skins (64x64) with player UV mappings.
*
* <p>Handles:
* <ul>
* <li>Dynamic texture URL rendering</li>
* <li>Full-skin mode (covers entire player model)</li>
* <li>Small arms mode (Alex/slim arms)</li>
* <li>Layer visibility control</li>
* <li>Steve vs Alex model selection</li>
* </ul>
*
* <p>This is called from BondageItemRenderLayer when rendering CLOTHES slot.
*/
@OnlyIn(Dist.CLIENT)
public class ClothesRenderHelper {
/**
* Attempt to render clothes with dynamic texture.
* Uses PlayerModel (not bondage model) because clothes use skin UV mappings.
*
* @param clothes The clothes ItemStack
* @param entity The entity wearing the clothes
* @param poseStack The pose stack
* @param buffer The render buffer
* @param packedLight The packed light value
* @param packedOverlay The packed overlay value (for hit flash effect)
* @param parentModel The parent PlayerModel to copy pose from
* @return true if dynamic texture was rendered, false otherwise
*/
public static boolean tryRenderDynamic(
ItemStack clothes,
LivingEntity entity,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
PlayerModel<?> parentModel
) {
if (
clothes.isEmpty() ||
!(clothes.getItem() instanceof GenericClothes gc)
) {
return false;
}
// Check if ClothesModelCache is initialized
if (!ClothesModelCache.isInitialized()) {
return false;
}
// Get properties from equipped clothes or remote player cache
ClothesProperties props = getClothesProperties(clothes, entity);
if (props == null) {
return false;
}
String dynamicUrl = props.dynamicTextureUrl();
if (dynamicUrl == null || dynamicUrl.isEmpty()) {
return false;
}
// Get texture from manager
DynamicTextureManager texManager = DynamicTextureManager.getInstance();
ResourceLocation texLocation = texManager.getTextureLocation(
dynamicUrl,
props.fullSkin()
);
if (texLocation == null) {
// Texture not loaded yet (downloading)
return false;
}
// Determine if we should use slim (Alex) arms
boolean useSlim = shouldUseSlimArms(clothes, entity, props);
// Get the appropriate PlayerModel from cache
PlayerModel<AbstractClientPlayer> clothesModel =
ClothesModelCache.getModel(useSlim);
if (clothesModel == null) {
return false;
}
// Copy pose from parent model to clothes model
copyPose(parentModel, clothesModel);
// Apply layer visibility settings
applyLayerVisibility(
clothesModel,
props.visibleLayers(),
props.keepHead()
);
// Render the clothes model with dynamic texture
// Use entityTranslucent for layered rendering (clothes on top of player)
VertexConsumer vertexConsumer = buffer.getBuffer(
RenderType.entityTranslucent(texLocation)
);
clothesModel.renderToBuffer(
poseStack,
vertexConsumer,
packedLight,
packedOverlay,
1.0f,
1.0f,
1.0f,
1.0f
);
// Restore visibility (model is shared, need to reset for next render)
restoreLayerVisibility(clothesModel);
return true;
}
/**
* Render clothes for NPCs (Damsel, etc.)
* Uses PlayerModel from cache, copies pose from parent HumanoidModel.
*
* @param clothes The clothes ItemStack
* @param entity The NPC entity wearing clothes
* @param poseStack The pose stack
* @param buffer The render buffer
* @param packedLight The packed light value
* @param packedOverlay The packed overlay value (for hit flash effect)
* @param parentModel The parent HumanoidModel to copy pose from
* @param hasSlimArms Whether the NPC uses slim arms
* @return true if rendered successfully
*/
public static boolean tryRenderDynamicForNPC(
ItemStack clothes,
LivingEntity entity,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
HumanoidModel<?> parentModel,
boolean hasSlimArms
) {
if (
clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes)
) {
return false;
}
if (!ClothesModelCache.isInitialized()) {
return false;
}
// Get properties directly from item (NPCs don't use remote cache)
ClothesProperties props = ClothesProperties.fromStack(clothes);
if (props == null) {
return false;
}
String dynamicUrl = props.dynamicTextureUrl();
if (dynamicUrl == null || dynamicUrl.isEmpty()) {
return false;
}
// Get texture from manager
ResourceLocation texLocation =
DynamicTextureManager.getInstance().getTextureLocation(
dynamicUrl,
props.fullSkin()
);
if (texLocation == null) {
return false; // Still downloading
}
// Use slim if: clothes force it OR NPC has slim arms
boolean useSlim = props.smallArms() || hasSlimArms;
// Get PlayerModel from cache (same cache as players)
PlayerModel<AbstractClientPlayer> clothesModel =
ClothesModelCache.getModel(useSlim);
if (clothesModel == null) {
return false;
}
// Copy pose from HumanoidModel (NPC) to PlayerModel (clothes)
copyPoseFromHumanoid(parentModel, clothesModel);
// Apply layer visibility
applyLayerVisibility(
clothesModel,
props.visibleLayers(),
props.keepHead()
);
// Render
VertexConsumer vertexConsumer = buffer.getBuffer(
RenderType.entityTranslucent(texLocation)
);
clothesModel.renderToBuffer(
poseStack,
vertexConsumer,
packedLight,
packedOverlay,
1.0f,
1.0f,
1.0f,
1.0f
);
// Restore visibility
restoreLayerVisibility(clothesModel);
return true;
}
/**
* Copy pose from HumanoidModel (NPC) to PlayerModel (clothes).
* PlayerModel has extra parts (jacket, sleeves, pants) that HumanoidModel lacks,
* so we copy those from the corresponding base parts.
*/
private static void copyPoseFromHumanoid(
HumanoidModel<?> source,
PlayerModel<?> dest
) {
// Base parts (exist in both models)
dest.head.copyFrom(source.head);
dest.hat.copyFrom(source.hat);
dest.body.copyFrom(source.body);
dest.rightArm.copyFrom(source.rightArm);
dest.leftArm.copyFrom(source.leftArm);
dest.rightLeg.copyFrom(source.rightLeg);
dest.leftLeg.copyFrom(source.leftLeg);
// PlayerModel-only parts: copy from corresponding base parts
dest.jacket.copyFrom(source.body);
dest.leftSleeve.copyFrom(source.leftArm);
dest.rightSleeve.copyFrom(source.rightArm);
dest.leftPants.copyFrom(source.leftLeg);
dest.rightPants.copyFrom(source.rightLeg);
// Animation flags
dest.crouching = source.crouching;
dest.riding = source.riding;
dest.young = source.young;
}
/**
* Copy pose from source PlayerModel to destination PlayerModel.
* This ensures the clothes model matches the player's current pose/animation.
*/
private static void copyPose(PlayerModel<?> source, PlayerModel<?> dest) {
// Main body parts
dest.head.copyFrom(source.head);
dest.hat.copyFrom(source.hat);
dest.body.copyFrom(source.body);
dest.rightArm.copyFrom(source.rightArm);
dest.leftArm.copyFrom(source.leftArm);
dest.rightLeg.copyFrom(source.rightLeg);
dest.leftLeg.copyFrom(source.leftLeg);
// Outer layer parts (jacket, sleeves, pants)
dest.jacket.copyFrom(source.jacket);
dest.leftSleeve.copyFrom(source.leftSleeve);
dest.rightSleeve.copyFrom(source.rightSleeve);
dest.leftPants.copyFrom(source.leftPants);
dest.rightPants.copyFrom(source.rightPants);
// Copy other properties
dest.crouching = source.crouching;
dest.young = source.young;
}
/**
* Determine if slim (Alex) arms should be used.
* Priority: 1) Clothes force small arms, 2) Player's actual model type
*/
private static boolean shouldUseSlimArms(
ItemStack clothes,
LivingEntity entity,
ClothesProperties props
) {
// 1. Check if clothes item forces small arms
if (props.smallArms()) {
return true;
}
// 2. Check local item setting
if (clothes.getItem() instanceof GenericClothes gc) {
if (gc.shouldForceSmallArms(clothes)) {
return true;
}
}
// 3. Follow player's actual model type
if (entity instanceof AbstractClientPlayer player) {
String modelName = player.getModelName();
return "slim".equals(modelName);
}
return false; // Default: normal (Steve) arms
}
/**
* Get clothes properties, checking both equipped item and remote player cache.
*/
@Nullable
private static ClothesProperties getClothesProperties(
ItemStack clothes,
LivingEntity entity
) {
// First try to get from the equipped item directly
ClothesProperties localProps = ClothesProperties.fromStack(clothes);
// If entity is a player, also check remote cache (for other players' clothes)
if (entity instanceof AbstractClientPlayer player) {
UUID playerUUID = player.getUUID();
ClothesClientCache.CachedClothesData cached =
ClothesClientCache.getPlayerClothes(playerUUID);
if (cached != null && cached.hasDynamicTexture()) {
// Use cached data (synced from server)
return new ClothesProperties(
cached.dynamicUrl(),
cached.fullSkin(),
cached.smallArms(),
cached.keepHead(),
cached.visibleLayers()
);
}
}
return localProps;
}
/**
* Apply layer visibility from clothes properties.
* Controls which parts of the outer layer (jacket, sleeves, pants) are visible.
*
* @param model The clothes PlayerModel
* @param visible Which layers are enabled on the clothes
* @param keepHead If true, hide the clothes head/hat (wearer's head shows instead)
*/
private static void applyLayerVisibility(
PlayerModel<?> model,
EnumSet<ClothesProperties.LayerPart> visible,
boolean keepHead
) {
// Main body parts visibility
// If keepHead is true, hide clothes head so wearer's head shows through
model.head.visible = !keepHead;
model.body.visible = true;
model.rightArm.visible = true;
model.leftArm.visible = true;
model.rightLeg.visible = true;
model.leftLeg.visible = true;
// Outer layer parts controlled by settings
// If keepHead is true, hide clothes hat so wearer's hat shows through
model.hat.visible =
!keepHead && visible.contains(ClothesProperties.LayerPart.HEAD);
model.jacket.visible = visible.contains(
ClothesProperties.LayerPart.BODY
);
model.leftSleeve.visible = visible.contains(
ClothesProperties.LayerPart.LEFT_ARM
);
model.rightSleeve.visible = visible.contains(
ClothesProperties.LayerPart.RIGHT_ARM
);
model.leftPants.visible = visible.contains(
ClothesProperties.LayerPart.LEFT_LEG
);
model.rightPants.visible = visible.contains(
ClothesProperties.LayerPart.RIGHT_LEG
);
}
/**
* Restore all layer visibility to default (all visible).
* Important because the model is cached and shared.
*/
private static void restoreLayerVisibility(PlayerModel<?> model) {
model.head.visible = true;
model.hat.visible = true;
model.body.visible = true;
model.jacket.visible = true;
model.rightArm.visible = true;
model.leftArm.visible = true;
model.rightSleeve.visible = true;
model.leftSleeve.visible = true;
model.rightLeg.visible = true;
model.leftLeg.visible = true;
model.rightPants.visible = true;
model.leftPants.visible = true;
}
/**
* Check if clothes should force small arms rendering.
* Public method for BondageItemRenderLayer.
*/
public static boolean shouldUseSmallArms(
ItemStack clothes,
LivingEntity entity
) {
if (
clothes.isEmpty() ||
!(clothes.getItem() instanceof GenericClothes gc)
) {
return false;
}
// Check local item
if (gc.shouldForceSmallArms(clothes)) {
return true;
}
// Check remote cache
if (entity instanceof AbstractClientPlayer player) {
return ClothesClientCache.isSmallArmsForced(player.getUUID());
}
return false;
}
/**
* Check if clothes have a dynamic texture (for deciding render path).
*/
public static boolean hasDynamicTexture(
ItemStack clothes,
LivingEntity entity
) {
if (
clothes.isEmpty() ||
!(clothes.getItem() instanceof GenericClothes gc)
) {
return false;
}
// Check local item
String localUrl = gc.getDynamicTextureUrl(clothes);
if (localUrl != null && !localUrl.isEmpty()) {
return true;
}
// Check remote cache
if (entity instanceof AbstractClientPlayer player) {
String cachedUrl = ClothesClientCache.getPlayerDynamicUrl(
player.getUUID()
);
return cachedUrl != null && !cachedUrl.isEmpty();
}
return false;
}
/**
* Check if full-skin mode is enabled for clothes.
*/
public static boolean isFullSkinMode(
ItemStack clothes,
LivingEntity entity
) {
if (
clothes.isEmpty() ||
!(clothes.getItem() instanceof GenericClothes gc)
) {
return false;
}
// Check local item
if (gc.isFullSkinEnabled(clothes)) {
return true;
}
// Check remote cache
if (entity instanceof AbstractClientPlayer player) {
return ClothesClientCache.isFullSkinEnabled(player.getUUID());
}
return false;
}
// ==================== Wearer Layer Hiding ====================
/**
* Hide wearer's outer layers when clothes are equipped.
* Called BEFORE rendering the base model.
*
* <p>Logic: When clothes are equipped, hide ALL wearer's outer layers.
* The clothes will render their own layers on top.
* Exception: If keepHead is enabled, the head/hat layers remain visible.
*
* @param model The wearer's PlayerModel
* @param props Clothes properties (used to confirm clothes are valid)
* @return Original visibility state for restoration (6 booleans)
*/
public static boolean[] hideWearerLayers(
PlayerModel<?> model,
ClothesProperties props
) {
if (props == null) {
return null;
}
// Save original state
boolean[] original = {
model.hat.visible,
model.jacket.visible,
model.leftSleeve.visible,
model.rightSleeve.visible,
model.leftPants.visible,
model.rightPants.visible,
};
// When wearing clothes, hide wearer's outer layers
// Exception: if keepHead is true, don't hide head/hat
if (!props.keepHead()) {
model.hat.visible = false;
}
model.jacket.visible = false;
model.leftSleeve.visible = false;
model.rightSleeve.visible = false;
model.leftPants.visible = false;
model.rightPants.visible = false;
return original;
}
/**
* Restore wearer's layer visibility after rendering.
*
* @param model The wearer's PlayerModel
* @param original Original visibility state from hideWearerLayers()
*/
public static void restoreWearerLayers(
PlayerModel<?> model,
boolean[] original
) {
if (original == null || original.length != 6) {
return;
}
model.hat.visible = original[0];
model.jacket.visible = original[1];
model.leftSleeve.visible = original[2];
model.rightSleeve.visible = original[3];
model.leftPants.visible = original[4];
model.rightPants.visible = original[5];
}
/**
* Get clothes properties for wearer layer hiding.
* Works for both players (with remote cache) and NPCs (direct from item).
*
* @param clothes The clothes ItemStack
* @param entity The entity wearing clothes
* @return ClothesProperties or null if not available
*/
public static ClothesProperties getPropsForLayerHiding(
ItemStack clothes,
LivingEntity entity
) {
if (
clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes)
) {
return null;
}
// For players, check remote cache first
if (entity instanceof AbstractClientPlayer player) {
ClothesClientCache.CachedClothesData cached =
ClothesClientCache.getPlayerClothes(player.getUUID());
if (cached != null) {
return new ClothesProperties(
cached.dynamicUrl(),
cached.fullSkin(),
cached.smallArms(),
cached.keepHead(),
cached.visibleLayers()
);
}
}
// Fall back to direct item properties
return ClothesProperties.fromStack(clothes);
}
}

View File

@@ -0,0 +1,144 @@
package com.tiedup.remake.client.renderer.models;
import net.minecraft.client.model.geom.ModelLayerLocation;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeDeformation;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.model.geom.builders.PartDefinition;
import net.minecraft.resources.ResourceLocation;
public class BondageLayerDefinitions {
public static final ModelLayerLocation BONDAGE_LAYER =
new ModelLayerLocation(
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_layer"),
"main"
);
public static final ModelLayerLocation BONDAGE_LAYER_SLIM =
new ModelLayerLocation(
ResourceLocation.fromNamespaceAndPath(
"tiedup",
"bondage_layer_slim"
),
"main"
);
/**
* Create bondage layer for normal arms (4px wide - Steve model).
*/
public static LayerDefinition createBodyLayer() {
return createBondageLayer(4);
}
/**
* Create bondage layer for slim arms (3px wide - Alex model).
*/
public static LayerDefinition createSlimBodyLayer() {
return createBondageLayer(3);
}
/**
* Create bondage layer with specified arm width.
*
* @param armWidth Arm width in pixels (4 for Steve, 3 for Alex)
*/
private static LayerDefinition createBondageLayer(int armWidth) {
MeshDefinition meshdefinition = new MeshDefinition();
PartDefinition partdefinition = meshdefinition.getRoot();
// Inflation for tight fit on body
CubeDeformation deformation = new CubeDeformation(0.35F);
// Head at standard position
partdefinition.addOrReplaceChild(
"head",
CubeListBuilder.create()
.texOffs(0, 0)
.addBox(-4.0F, -8.0F, -4.0F, 8.0F, 8.0F, 8.0F, deformation),
PartPose.offset(0.0F, 0.0F, 0.0F)
);
partdefinition.addOrReplaceChild(
"hat",
CubeListBuilder.create()
.texOffs(32, 0)
.addBox(
-4.0F,
-8.0F,
-4.0F,
8.0F,
8.0F,
8.0F,
deformation.extend(0.1F)
),
PartPose.offset(0.0F, 0.0F, 0.0F)
);
partdefinition.addOrReplaceChild(
"body",
CubeListBuilder.create()
.texOffs(16, 16)
.addBox(-4.0F, 0.0F, -2.0F, 8.0F, 12.0F, 4.0F, deformation),
PartPose.offset(0.0F, 0.0F, 0.0F)
);
// Arms - width varies based on model type
float armWidthF = (float) armWidth;
float rightArmX = -(armWidthF - 1.0F); // -3 for 4px, -2 for 3px
partdefinition.addOrReplaceChild(
"right_arm",
CubeListBuilder.create()
.texOffs(40, 16)
.addBox(
rightArmX,
-2.0F,
-2.0F,
armWidthF,
12.0F,
4.0F,
deformation
),
PartPose.offset(-5.0F, 2.0F, 0.0F)
);
partdefinition.addOrReplaceChild(
"left_arm",
CubeListBuilder.create()
.texOffs(40, 16)
.mirror()
.addBox(
-1.0F,
-2.0F,
-2.0F,
armWidthF,
12.0F,
4.0F,
deformation
),
PartPose.offset(5.0F, 2.0F, 0.0F)
);
partdefinition.addOrReplaceChild(
"right_leg",
CubeListBuilder.create()
.texOffs(0, 16)
.addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation),
PartPose.offset(-1.9F, 12.0F, 0.0F)
);
partdefinition.addOrReplaceChild(
"left_leg",
CubeListBuilder.create()
.texOffs(0, 16)
.mirror()
.addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation),
PartPose.offset(1.9F, 12.0F, 0.0F)
);
return LayerDefinition.create(meshdefinition, 64, 32);
}
}

View File

@@ -0,0 +1,44 @@
package com.tiedup.remake.client.renderer.obj;
/**
* Immutable record representing a triangular face in an OBJ model.
* Contains exactly 3 vertices forming a triangle.
*/
public record ObjFace(ObjVertex v0, ObjVertex v1, ObjVertex v2) {
/**
* Get the vertices as an array for iteration.
*/
public ObjVertex[] vertices() {
return new ObjVertex[] { v0, v1, v2 };
}
/**
* Calculate the face normal from vertices (cross product of edges).
* Useful when the OBJ file doesn't provide normals.
*/
public float[] calculateNormal() {
// Edge vectors
float e1x = v1.x() - v0.x();
float e1y = v1.y() - v0.y();
float e1z = v1.z() - v0.z();
float e2x = v2.x() - v0.x();
float e2y = v2.y() - v0.y();
float e2z = v2.z() - v0.z();
// Cross product
float nx = e1y * e2z - e1z * e2y;
float ny = e1z * e2x - e1x * e2z;
float nz = e1x * e2y - e1y * e2x;
// Normalize
float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0.0001f) {
nx /= len;
ny /= len;
nz /= len;
}
return new float[] { nx, ny, nz };
}
}

View File

@@ -0,0 +1,57 @@
package com.tiedup.remake.client.renderer.obj;
import org.jetbrains.annotations.Nullable;
/**
* Immutable record representing a material from an MTL file.
* Contains the material name, diffuse color (Kd), and optional diffuse texture (map_Kd).
*/
public record ObjMaterial(
String name,
float r,
float g,
float b,
@Nullable String texturePath
) {
/**
* Default white material used when no material is specified.
*/
public static final ObjMaterial DEFAULT = new ObjMaterial(
"default",
1.0f,
1.0f,
1.0f,
null
);
/**
* Create a material with just color (no texture).
*/
public ObjMaterial(String name, float r, float g, float b) {
this(name, r, g, b, null);
}
/**
* Create a material with grayscale color.
*/
public static ObjMaterial ofGrayscale(String name, float value) {
return new ObjMaterial(name, value, value, value, null);
}
/**
* Check if this material has a diffuse texture.
*/
public boolean hasTexture() {
return texturePath != null && !texturePath.isEmpty();
}
/**
* Get the color as a packed ARGB int (with full alpha).
*/
public int toARGB() {
int ri = (int) (r * 255) & 0xFF;
int gi = (int) (g * 255) & 0xFF;
int bi = (int) (b * 255) & 0xFF;
return 0xFF000000 | (ri << 16) | (gi << 8) | bi;
}
}

View File

@@ -0,0 +1,258 @@
package com.tiedup.remake.client.renderer.obj;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Container for a loaded OBJ model.
* Holds faces grouped by material and material definitions.
* Immutable after construction.
*/
public class ObjModel {
private final Map<String, List<ObjFace>> facesByMaterial;
private final Map<String, ObjMaterial> materials;
private final int totalFaces;
private final int totalVertices;
/** Base path for resolving relative texture paths (e.g., "tiedup:textures/models/obj/") */
@Nullable
private final String textureBasePath;
// Bounding box (AABB)
private final float minX, minY, minZ;
private final float maxX, maxY, maxZ;
private ObjModel(Builder builder) {
this.facesByMaterial = Collections.unmodifiableMap(
new HashMap<>(builder.facesByMaterial)
);
this.materials = Collections.unmodifiableMap(
new HashMap<>(builder.materials)
);
this.textureBasePath = builder.textureBasePath;
// Calculate totals
int faces = 0;
for (List<ObjFace> faceList : this.facesByMaterial.values()) {
faces += faceList.size();
}
this.totalFaces = faces;
this.totalVertices = faces * 3;
// Calculate AABB
float minX = Float.MAX_VALUE,
minY = Float.MAX_VALUE,
minZ = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE,
maxY = -Float.MAX_VALUE,
maxZ = -Float.MAX_VALUE;
for (List<ObjFace> faceList : this.facesByMaterial.values()) {
for (ObjFace face : faceList) {
for (ObjVertex v : face.vertices()) {
minX = Math.min(minX, v.x());
minY = Math.min(minY, v.y());
minZ = Math.min(minZ, v.z());
maxX = Math.max(maxX, v.x());
maxY = Math.max(maxY, v.y());
maxZ = Math.max(maxZ, v.z());
}
}
}
this.minX = minX == Float.MAX_VALUE ? 0 : minX;
this.minY = minY == Float.MAX_VALUE ? 0 : minY;
this.minZ = minZ == Float.MAX_VALUE ? 0 : minZ;
this.maxX = maxX == -Float.MAX_VALUE ? 0 : maxX;
this.maxY = maxY == -Float.MAX_VALUE ? 0 : maxY;
this.maxZ = maxZ == -Float.MAX_VALUE ? 0 : maxZ;
}
/**
* Get all faces grouped by material name.
*/
public Map<String, List<ObjFace>> getFacesByMaterial() {
return facesByMaterial;
}
/**
* Get a material by name.
* Returns default white material if not found.
*/
public ObjMaterial getMaterial(String name) {
return materials.getOrDefault(name, ObjMaterial.DEFAULT);
}
/**
* Get all materials.
*/
public Map<String, ObjMaterial> getMaterials() {
return materials;
}
/**
* Get total number of triangles in the model.
*/
public int getTotalFaces() {
return totalFaces;
}
/**
* Get total number of vertices (faces * 3).
*/
public int getTotalVertices() {
return totalVertices;
}
// AABB getters
public float getMinX() {
return minX;
}
public float getMinY() {
return minY;
}
public float getMinZ() {
return minZ;
}
public float getMaxX() {
return maxX;
}
public float getMaxY() {
return maxY;
}
public float getMaxZ() {
return maxZ;
}
/**
* Get the center point of the model's bounding box.
*/
public float[] getCenter() {
return new float[] {
(minX + maxX) / 2f,
(minY + maxY) / 2f,
(minZ + maxZ) / 2f,
};
}
/**
* Get the dimensions of the bounding box.
*/
public float[] getDimensions() {
return new float[] { maxX - minX, maxY - minY, maxZ - minZ };
}
/**
* Resolve a texture filename to a full ResourceLocation.
* Uses the textureBasePath set during loading.
*
* @param filename The texture filename (e.g., "ball_gag.png")
* @return Full ResourceLocation, or null if no base path is set
*/
@Nullable
public ResourceLocation resolveTexture(String filename) {
if (textureBasePath == null || filename == null) {
return null;
}
return ResourceLocation.fromNamespaceAndPath(
"tiedup",
textureBasePath + filename
);
}
/**
* Resolve a texture filename with a color suffix inserted before the extension.
* Example: "texture.png" + "red" -> "texture_red.png"
*
* @param filename The base texture filename (e.g., "texture.png")
* @param colorSuffix The color suffix to insert (e.g., "red"), or null for no suffix
* @return Full ResourceLocation with color suffix, or null if no base path is set
*/
@Nullable
public ResourceLocation resolveTextureWithColorSuffix(
String filename,
@Nullable String colorSuffix
) {
if (textureBasePath == null || filename == null) {
return null;
}
if (colorSuffix == null || colorSuffix.isEmpty()) {
return resolveTexture(filename);
}
// Insert color suffix before extension: "texture.png" -> "texture_red.png"
int dotIndex = filename.lastIndexOf('.');
String newFilename;
if (dotIndex > 0) {
newFilename =
filename.substring(0, dotIndex) +
"_" +
colorSuffix +
filename.substring(dotIndex);
} else {
newFilename = filename + "_" + colorSuffix;
}
return ResourceLocation.fromNamespaceAndPath(
"tiedup",
textureBasePath + newFilename
);
}
/**
* Get the texture base path.
*/
@Nullable
public String getTextureBasePath() {
return textureBasePath;
}
/**
* Builder for constructing ObjModel instances.
*/
public static class Builder {
private final Map<String, List<ObjFace>> facesByMaterial =
new HashMap<>();
private final Map<String, ObjMaterial> materials = new HashMap<>();
private String textureBasePath;
public Builder addFaces(String materialName, List<ObjFace> faces) {
if (faces != null && !faces.isEmpty()) {
facesByMaterial.put(materialName, List.copyOf(faces));
}
return this;
}
public Builder addMaterial(ObjMaterial material) {
if (material != null) {
materials.put(material.name(), material);
}
return this;
}
public Builder setTextureBasePath(String path) {
this.textureBasePath = path;
return this;
}
public ObjModel build() {
return new ObjModel(this);
}
}
/**
* Create a new builder.
*/
public static Builder builder() {
return new Builder();
}
}

View File

@@ -0,0 +1,417 @@
package com.tiedup.remake.client.renderer.obj;
import com.tiedup.remake.core.TiedUpMod;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import org.jetbrains.annotations.Nullable;
/**
* Parses .obj and .mtl files from ResourceLocations.
* Supports:
* - Vertex positions (v)
* - Texture coordinates (vt)
* - Vertex normals (vn)
* - Faces (f) with v/vt/vn format, triangles and quads
* - Material library (mtllib)
* - Material assignment (usemtl)
* - MTL diffuse color (Kd)
*/
public class ObjModelLoader {
private ObjModelLoader() {
// Utility class
}
/**
* Load an OBJ model from a ResourceLocation.
*
* @param location ResourceLocation pointing to the .obj file
* @return Loaded ObjModel, or null if loading failed
*/
@Nullable
public static ObjModel load(ResourceLocation location) {
ResourceManager resourceManager =
Minecraft.getInstance().getResourceManager();
try {
Optional<Resource> resourceOpt = resourceManager.getResource(
location
);
if (resourceOpt.isEmpty()) {
TiedUpMod.LOGGER.warn("OBJ file not found: {}", location);
return null;
}
Resource resource = resourceOpt.get();
// Parse context
List<float[]> positions = new ArrayList<>();
List<float[]> texCoords = new ArrayList<>();
List<float[]> normals = new ArrayList<>();
Map<String, List<ObjFace>> facesByMaterial = new HashMap<>();
Map<String, ObjMaterial> materials = new HashMap<>();
String currentMaterial = "default";
String mtlLib = null;
// Parse OBJ file
try (
BufferedReader reader = new BufferedReader(
new InputStreamReader(
resource.open(),
StandardCharsets.UTF_8
)
)
) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\s+");
if (parts.length == 0) continue;
switch (parts[0]) {
case "v" -> {
// Vertex position: v x y z
if (parts.length >= 4) {
positions.add(
new float[] {
parseFloat(parts[1]),
parseFloat(parts[2]),
parseFloat(parts[3]),
}
);
}
}
case "vt" -> {
// Texture coordinate: vt u v
if (parts.length >= 3) {
texCoords.add(
new float[] {
parseFloat(parts[1]),
parseFloat(parts[2]),
}
);
}
}
case "vn" -> {
// Vertex normal: vn x y z
if (parts.length >= 4) {
normals.add(
new float[] {
parseFloat(parts[1]),
parseFloat(parts[2]),
parseFloat(parts[3]),
}
);
}
}
case "mtllib" -> {
// Material library: mtllib filename.mtl
if (parts.length >= 2) {
mtlLib = parts[1];
}
}
case "usemtl" -> {
// Use material: usemtl MaterialName
if (parts.length >= 2) {
currentMaterial = parts[1];
}
}
case "f" -> {
// Face: f v/vt/vn v/vt/vn v/vt/vn [v/vt/vn]
List<ObjVertex> faceVerts = new ArrayList<>();
for (int i = 1; i < parts.length; i++) {
ObjVertex vert = parseVertex(
parts[i],
positions,
texCoords,
normals
);
if (vert != null) {
faceVerts.add(vert);
}
}
// Triangulate if needed (quad -> 2 triangles)
List<ObjFace> faces = triangulate(faceVerts);
facesByMaterial
.computeIfAbsent(currentMaterial, k ->
new ArrayList<>()
)
.addAll(faces);
}
}
}
}
// Load MTL file if referenced
if (mtlLib != null) {
ResourceLocation mtlLocation = resolveMtlPath(location, mtlLib);
Map<String, ObjMaterial> loadedMaterials = loadMtl(mtlLocation);
materials.putAll(loadedMaterials);
}
// Build and return model
ObjModel.Builder builder = ObjModel.builder();
for (Map.Entry<
String,
List<ObjFace>
> entry : facesByMaterial.entrySet()) {
builder.addFaces(entry.getKey(), entry.getValue());
}
for (ObjMaterial mat : materials.values()) {
builder.addMaterial(mat);
}
// Set texture base path (textures are now in the same directory as the OBJ)
String objPath = location.getPath();
int lastSlash = objPath.lastIndexOf('/');
String directory =
lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : "";
// Textures are in the same folder as the OBJ file
String textureBasePath = directory;
builder.setTextureBasePath(textureBasePath);
ObjModel model = builder.build();
TiedUpMod.LOGGER.info(
"Loaded OBJ model: {} ({} faces, {} materials)",
location,
model.getTotalFaces(),
materials.size()
);
return model;
} catch (Exception e) {
TiedUpMod.LOGGER.error("Failed to load OBJ model: {}", location, e);
return null;
}
}
/**
* Parse a single vertex from face data.
* Format: v/vt/vn or v//vn or v/vt or v
*/
@Nullable
private static ObjVertex parseVertex(
String data,
List<float[]> positions,
List<float[]> texCoords,
List<float[]> normals
) {
String[] indices = data.split("/");
if (indices.length == 0) return null;
// Position index (required)
int posIdx = parseInt(indices[0]) - 1; // OBJ indices are 1-based
if (posIdx < 0 || posIdx >= positions.size()) return null;
float[] pos = positions.get(posIdx);
// Texture coordinate index (optional)
float u = 0,
v = 0;
if (indices.length >= 2 && !indices[1].isEmpty()) {
int texIdx = parseInt(indices[1]) - 1;
if (texIdx >= 0 && texIdx < texCoords.size()) {
float[] tex = texCoords.get(texIdx);
u = tex[0];
v = tex[1];
}
}
// Normal index (optional)
float nx = 0,
ny = 1,
nz = 0;
if (indices.length >= 3 && !indices[2].isEmpty()) {
int normIdx = parseInt(indices[2]) - 1;
if (normIdx >= 0 && normIdx < normals.size()) {
float[] norm = normals.get(normIdx);
nx = norm[0];
ny = norm[1];
nz = norm[2];
}
}
return new ObjVertex(pos[0], pos[1], pos[2], u, v, nx, ny, nz);
}
/**
* Triangulate a face (convert quad to 2 triangles).
*/
private static List<ObjFace> triangulate(List<ObjVertex> vertices) {
List<ObjFace> result = new ArrayList<>();
if (vertices.size() < 3) {
return result;
}
// Triangle
if (vertices.size() == 3) {
result.add(
new ObjFace(vertices.get(0), vertices.get(1), vertices.get(2))
);
}
// Quad -> 2 triangles (fan triangulation)
else if (vertices.size() >= 4) {
ObjVertex v0 = vertices.get(0);
for (int i = 1; i < vertices.size() - 1; i++) {
result.add(
new ObjFace(v0, vertices.get(i), vertices.get(i + 1))
);
}
}
return result;
}
/**
* Resolve the MTL file path relative to the OBJ file.
*/
private static ResourceLocation resolveMtlPath(
ResourceLocation objLocation,
String mtlFileName
) {
String objPath = objLocation.getPath();
int lastSlash = objPath.lastIndexOf('/');
String directory =
lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : "";
return ResourceLocation.fromNamespaceAndPath(
objLocation.getNamespace(),
directory + mtlFileName
);
}
/**
* Load materials from an MTL file.
*/
private static Map<String, ObjMaterial> loadMtl(ResourceLocation location) {
Map<String, ObjMaterial> materials = new HashMap<>();
ResourceManager resourceManager =
Minecraft.getInstance().getResourceManager();
try {
Optional<Resource> resourceOpt = resourceManager.getResource(
location
);
if (resourceOpt.isEmpty()) {
TiedUpMod.LOGGER.warn("MTL file not found: {}", location);
return materials;
}
Resource resource = resourceOpt.get();
String currentName = null;
float r = 1,
g = 1,
b = 1;
String texturePath = null;
try (
BufferedReader reader = new BufferedReader(
new InputStreamReader(
resource.open(),
StandardCharsets.UTF_8
)
)
) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\s+");
if (parts.length == 0) continue;
switch (parts[0]) {
case "newmtl" -> {
// Save previous material
if (currentName != null) {
materials.put(
currentName,
new ObjMaterial(
currentName,
r,
g,
b,
texturePath
)
);
}
// Start new material
currentName =
parts.length >= 2 ? parts[1] : "unnamed";
r = 1;
g = 1;
b = 1; // Reset to default
texturePath = null;
}
case "Kd" -> {
// Diffuse color: Kd r g b
if (parts.length >= 4) {
r = parseFloat(parts[1]);
g = parseFloat(parts[2]);
b = parseFloat(parts[3]);
}
}
case "map_Kd" -> {
// Diffuse texture map: map_Kd filename.png
if (parts.length >= 2) {
texturePath = parts[1];
}
}
}
}
// Save last material
if (currentName != null) {
materials.put(
currentName,
new ObjMaterial(currentName, r, g, b, texturePath)
);
}
}
TiedUpMod.LOGGER.debug(
"Loaded {} materials from MTL: {}",
materials.size(),
location
);
} catch (IOException e) {
TiedUpMod.LOGGER.error("Failed to load MTL file: {}", location, e);
}
return materials;
}
private static float parseFloat(String s) {
try {
return Float.parseFloat(s);
} catch (NumberFormatException e) {
return 0f;
}
}
private static int parseInt(String s) {
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -0,0 +1,117 @@
package com.tiedup.remake.client.renderer.obj;
import com.tiedup.remake.core.TiedUpMod;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* Singleton registry/cache for loaded OBJ models.
* Models are loaded on-demand and cached for reuse.
* Client-side only.
*/
@OnlyIn(Dist.CLIENT)
public class ObjModelRegistry {
private static final Map<ResourceLocation, ObjModel> CACHE =
new HashMap<>();
private static boolean initialized = false;
private ObjModelRegistry() {
// Singleton utility class
}
/**
* Initialize the registry and preload known models.
* Called during FMLClientSetupEvent.
*/
public static void init() {
if (initialized) {
return;
}
TiedUpMod.LOGGER.info("Initializing ObjModelRegistry...");
// Preload known 3D models
preloadModel("tiedup:models/obj/ball_gag/model.obj");
preloadModel("tiedup:models/obj/choke_collar_leather/model.obj");
initialized = true;
TiedUpMod.LOGGER.info(
"ObjModelRegistry initialized with {} models",
CACHE.size()
);
}
/**
* Preload a model into the cache.
*
* @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj")
*/
private static void preloadModel(String path) {
ResourceLocation location = ResourceLocation.tryParse(path);
if (location != null) {
ObjModel model = ObjModelLoader.load(location);
if (model != null) {
CACHE.put(location, model);
TiedUpMod.LOGGER.debug("Preloaded OBJ model: {}", path);
} else {
TiedUpMod.LOGGER.warn("Failed to preload OBJ model: {}", path);
}
}
}
/**
* Get a model from the cache, loading it if not present.
*
* @param location ResourceLocation of the .obj file
* @return The loaded model, or null if not found/failed to load
*/
@Nullable
public static ObjModel get(ResourceLocation location) {
if (location == null) {
return null;
}
return CACHE.computeIfAbsent(location, ObjModelLoader::load);
}
/**
* Get a model by string path.
*
* @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj")
* @return The loaded model, or null if not found/failed to load
*/
@Nullable
public static ObjModel get(String path) {
ResourceLocation location = ResourceLocation.tryParse(path);
return location != null ? get(location) : null;
}
/**
* Check if a model is loaded in the cache.
*/
public static boolean isLoaded(ResourceLocation location) {
return CACHE.containsKey(location);
}
/**
* Clear the cache.
* Useful for resource reload events.
*/
public static void clearCache() {
CACHE.clear();
initialized = false;
TiedUpMod.LOGGER.info("ObjModelRegistry cache cleared");
}
/**
* Get the number of loaded models.
*/
public static int getCachedCount() {
return CACHE.size();
}
}

View File

@@ -0,0 +1,406 @@
package com.tiedup.remake.client.renderer.obj;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat;
import java.util.List;
import java.util.Map;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderStateShard;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
/**
* Renders OBJ models using Minecraft's rendering system.
* Uses VertexConsumer for hardware-accelerated rendering.
* Client-side only.
*
* <p><b>Important:</b> Uses TRIANGLES mode RenderType, not QUADS,
* because OBJ models are triangulated during loading.
*/
@OnlyIn(Dist.CLIENT)
public class ObjModelRenderer extends RenderStateShard {
/** White texture for vertex color rendering */
public static final ResourceLocation WHITE_TEXTURE =
ResourceLocation.fromNamespaceAndPath(
"tiedup",
"models/obj/shared/white.png"
);
private ObjModelRenderer() {
super("tiedup_obj_renderer", () -> {}, () -> {});
// Utility class - extends RenderStateShard to access protected members
}
/**
* Create a TRIANGLES-mode RenderType for OBJ rendering.
* Standard entityCutoutNoCull uses QUADS which causes spiky artifacts
* when we submit triangles.
*/
private static RenderType createTriangleRenderType(
ResourceLocation texture
) {
RenderType.CompositeState state = RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
.setTextureState(
new RenderStateShard.TextureStateShard(texture, false, false)
)
.setTransparencyState(NO_TRANSPARENCY)
.setCullState(NO_CULL)
.setLightmapState(LIGHTMAP)
.setOverlayState(OVERLAY)
.createCompositeState(true);
return RenderType.create(
"tiedup_obj_triangles",
DefaultVertexFormat.NEW_ENTITY,
VertexFormat.Mode.TRIANGLES, // Key fix: TRIANGLES not QUADS
1536,
true,
false,
state
);
}
/**
* Render an OBJ model with vertex colors from materials.
*
* @param model The OBJ model to render
* @param poseStack The current pose stack (with transformations applied)
* @param buffer The multi-buffer source
* @param packedLight Packed light value
* @param packedOverlay Packed overlay value (for hit flash etc.)
*/
public static void render(
ObjModel model,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay
) {
render(
model,
poseStack,
buffer,
packedLight,
packedOverlay,
1.0f,
1.0f,
1.0f,
1.0f
);
}
/**
* Render an OBJ model with textures from materials (map_Kd) or vertex colors as fallback.
*
* @param model The OBJ model to render
* @param poseStack The current pose stack (with transformations applied)
* @param buffer The multi-buffer source
* @param packedLight Packed light value
* @param packedOverlay Packed overlay value (for hit flash etc.)
* @param tintR Red tint multiplier (0-1)
* @param tintG Green tint multiplier (0-1)
* @param tintB Blue tint multiplier (0-1)
* @param alpha Alpha value (0-1)
*/
public static void render(
ObjModel model,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
float tintR,
float tintG,
float tintB,
float alpha
) {
if (model == null) {
return;
}
Matrix4f pose = poseStack.last().pose();
Matrix3f normal = poseStack.last().normal();
// Render faces grouped by material
for (Map.Entry<String, List<ObjFace>> entry : model
.getFacesByMaterial()
.entrySet()) {
ObjMaterial material = model.getMaterial(entry.getKey());
// Determine texture and color based on material
ResourceLocation texture;
float r, g, b;
if (material.hasTexture()) {
// Use texture from map_Kd - resolve to full path
texture = model.resolveTexture(material.texturePath());
if (texture == null) {
texture = WHITE_TEXTURE;
}
// White color to let texture show through, with tint applied
r = tintR;
g = tintG;
b = tintB;
} else {
// Use vertex color from Kd
texture = WHITE_TEXTURE;
r = material.r() * tintR;
g = material.g() * tintG;
b = material.b() * tintB;
}
// Get buffer for this material's texture (TRIANGLES mode)
VertexConsumer vertexConsumer = buffer.getBuffer(
createTriangleRenderType(texture)
);
for (ObjFace face : entry.getValue()) {
ObjVertex[] verts = face.vertices();
// Use vertex normals from the OBJ file for smooth shading
for (ObjVertex vertex : verts) {
vertexConsumer
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
.color(r, g, b, alpha)
.uv(vertex.u(), 1.0f - vertex.v())
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
.endVertex();
}
}
}
}
/**
* Render an OBJ model with selective material tinting.
* Only materials in the tintMaterials set will be tinted.
*
* @param model The OBJ model to render
* @param poseStack The current pose stack
* @param buffer The multi-buffer source
* @param packedLight Packed light value
* @param packedOverlay Packed overlay value
* @param tintR Red tint (0-1)
* @param tintG Green tint (0-1)
* @param tintB Blue tint (0-1)
* @param tintMaterials Set of material names to apply tint to (e.g., "Ball")
*/
public static void renderWithSelectiveTint(
ObjModel model,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
float tintR,
float tintG,
float tintB,
java.util.Set<String> tintMaterials
) {
if (model == null) {
return;
}
Matrix4f pose = poseStack.last().pose();
Matrix3f normal = poseStack.last().normal();
for (Map.Entry<String, List<ObjFace>> entry : model
.getFacesByMaterial()
.entrySet()) {
String materialName = entry.getKey();
ObjMaterial material = model.getMaterial(materialName);
// Check if this material should be tinted
boolean shouldTint = tintMaterials.contains(materialName);
ResourceLocation texture;
float r, g, b;
if (material.hasTexture()) {
texture = model.resolveTexture(material.texturePath());
if (texture == null) {
texture = WHITE_TEXTURE;
}
// Apply tint only to specified materials
if (shouldTint) {
r = tintR;
g = tintG;
b = tintB;
} else {
r = 1.0f;
g = 1.0f;
b = 1.0f;
}
} else {
// Vertex color from Kd
texture = WHITE_TEXTURE;
if (shouldTint) {
r = material.r() * tintR;
g = material.g() * tintG;
b = material.b() * tintB;
} else {
r = material.r();
g = material.g();
b = material.b();
}
}
VertexConsumer vertexConsumer = buffer.getBuffer(
createTriangleRenderType(texture)
);
for (ObjFace face : entry.getValue()) {
for (ObjVertex vertex : face.vertices()) {
vertexConsumer
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
.color(r, g, b, 1.0f)
.uv(vertex.u(), 1.0f - vertex.v())
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
.endVertex();
}
}
}
}
/**
* Render an OBJ model with color-suffixed textures.
* When colorSuffix is provided, ALL textured materials use texture_COLOR.png.
*
* @param model The OBJ model to render
* @param poseStack The current pose stack
* @param buffer The multi-buffer source
* @param packedLight Packed light value
* @param packedOverlay Packed overlay value
* @param colorSuffix The color suffix (e.g., "red", "blue"), or null for default texture
*/
public static void renderWithColoredTextures(
ObjModel model,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
String colorSuffix
) {
if (model == null) {
return;
}
Matrix4f pose = poseStack.last().pose();
Matrix3f normal = poseStack.last().normal();
for (Map.Entry<String, List<ObjFace>> entry : model
.getFacesByMaterial()
.entrySet()) {
ObjMaterial material = model.getMaterial(entry.getKey());
ResourceLocation texture;
if (material.hasTexture()) {
// Apply color suffix to texture path if provided
if (colorSuffix != null && !colorSuffix.isEmpty()) {
texture = model.resolveTextureWithColorSuffix(
material.texturePath(),
colorSuffix
);
} else {
texture = model.resolveTexture(material.texturePath());
}
if (texture == null) {
texture = WHITE_TEXTURE;
}
} else {
// No texture - use vertex color from Kd
texture = WHITE_TEXTURE;
}
VertexConsumer vertexConsumer = buffer.getBuffer(
createTriangleRenderType(texture)
);
// Determine vertex color - white for textured, Kd for untextured
float r, g, b;
if (material.hasTexture()) {
r = 1.0f;
g = 1.0f;
b = 1.0f;
} else {
r = material.r();
g = material.g();
b = material.b();
}
for (ObjFace face : entry.getValue()) {
for (ObjVertex vertex : face.vertices()) {
vertexConsumer
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
.color(r, g, b, 1.0f)
.uv(vertex.u(), 1.0f - vertex.v())
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
.endVertex();
}
}
}
}
/**
* Render an OBJ model with a specific texture instead of vertex colors.
*
* @param model The OBJ model to render
* @param poseStack The current pose stack (with transformations applied)
* @param buffer The multi-buffer source
* @param packedLight Packed light value
* @param packedOverlay Packed overlay value
* @param texture The texture to apply
*/
public static void renderTextured(
ObjModel model,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
ResourceLocation texture
) {
if (model == null) {
return;
}
Matrix4f pose = poseStack.last().pose();
Matrix3f normal = poseStack.last().normal();
// Use custom TRIANGLES-mode RenderType
VertexConsumer vertexConsumer = buffer.getBuffer(
createTriangleRenderType(texture)
);
// Render all faces with white color (let texture provide color)
for (Map.Entry<String, List<ObjFace>> entry : model
.getFacesByMaterial()
.entrySet()) {
for (ObjFace face : entry.getValue()) {
ObjVertex[] verts = face.vertices();
for (ObjVertex vertex : verts) {
vertexConsumer
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
.color(1.0f, 1.0f, 1.0f, 1.0f)
.uv(vertex.u(), 1.0f - vertex.v())
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
.endVertex();
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
package com.tiedup.remake.client.renderer.obj;
/**
* Immutable record representing a single vertex in an OBJ model.
* Contains position (x, y, z), texture coordinates (u, v), and normal (nx, ny, nz).
*/
public record ObjVertex(
float x,
float y,
float z,
float u,
float v,
float nx,
float ny,
float nz
) {
/**
* Create a vertex with only position data.
* UV and normal will be set to defaults.
*/
public static ObjVertex ofPosition(float x, float y, float z) {
return new ObjVertex(x, y, z, 0f, 0f, 0f, 1f, 0f);
}
/**
* Create a vertex with position and UV.
* Normal will be set to default (pointing up).
*/
public static ObjVertex ofPositionUV(
float x,
float y,
float z,
float u,
float v
) {
return new ObjVertex(x, y, z, u, v, 0f, 1f, 0f);
}
}