客户端渲染:移动中的方块如何画出来
结构移动期间方块本体还停在原位(直到动画结束才真正 setBlock)。视觉上"方块在滑动"是客户端独立画的一层半透明重绘。核心对象是
MovingRenderer。
代码位置:src/main/scala/mrtjp/relocation/renders.scala(204 行)+ src/main/scala/mrtjp/relocation/hacks.scala(64 行)。
为什么需要独立的渲染层
移动进度只存在于 BlockStruct.progress 这个服务端状态里,从不写进方块 metadata。因此 RenderBlocks 照常按原坐标画一遍静态方块,移动中的那份必须额外重绘。两份叠加才形成"滑动的方块"。
MovingRenderer 的三个状态量:
object MovingRenderer {
var isRendering = false
var renderHack = true
private var oldWorld: World = null
private var frame = 0.0f
private var renderBlocks: RenderBlocks = null
}
renderHack 是给 ASM 核心 mod 用的互斥开关,防止自己重绘时又触发被改写的原版渲染路径(见下文 getRenderType)。
事件驱动
RelocationClientEventHandler 订阅三个事件:
| 事件 | 相位 | 调用 |
|---|---|---|
TickEvent.RenderTickEvent |
START |
MovingRenderer.onPreRenderTick(e.renderTickTime) — 置 isRendering = true,frame = time |
TickEvent.RenderTickEvent |
END |
MovingRenderer.onPostRenderTick() — 置 isRendering = false |
RenderWorldLastEvent |
— | MovingRenderer.onRenderWorldEvent() — 实际重绘发生在这里 |
TickEvent.ClientTickEvent |
END |
MovementManager2.onTick(true) + MovingRenderer.onTick() |
onTick() 只处理世界切换(oldWorld != null && world == null 时清空缓存)。onRenderWorldEvent() 在世界对象变化时重建渲染器:
def onRenderWorldEvent() {
if (oldWorld != world) {
oldWorld = world
renderBlocks = new MovingRenderBlocks(new MovingWorld(world))
renderBlocks.renderAllFaces = true
}
MovementManager2.getWorldStructs(world).foreachStruct(renderStruct)
}
renderAllFaces = true —— 移动中的方块六个面全画,即使被遮挡也画(否则滑进墙里的那部分会消失)。renderStruct 对每个结构的 preMoveBlocks 逐个调用 render(b.x, b.y, b.z, renderPos(s, frame))。
位置插值
def renderPos(s: BlockStruct, partial: Float) =
new Vector3(BlockCoord.sideOffsets(s.moveDir)).multiply(s.progress + s.speed * partial)
partial 是本 tick 已过去的比例,所以 progress + speed * partial 在两个整数 tick 之间做线性插值,得到平滑到亚 tick 精度的位移。三个分量分别被 MathLib.clamp(-1f, 1f, ...) 限在 [-1, 1]——保证不会画出格外。
单个方块的重绘流程
render(x, y, z, rpos) 的完整 GL 状态操作:
val oldOcclusion = mc.gameSettings.ambientOcclusion
mc.gameSettings.ambientOcclusion = 0
...
mc.entityRenderer.enableLightmap(frame)
val light = world.getLightBrightnessForSkyBlocks(x, y, z, block.getLightValue(world, x, y, z))
val l1 = light % 65536
val l2 = light / 65536
OpenGlHelper.setLightmapTextureCoords(OpenGlHelper.lightmapTexUnit, l1, l2)
glColor4f(0, 0, 0, 0) // ← 透明
RenderHelper.disableStandardItemLighting()
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_BLEND)
glDisable(GL_CULL_FACE)
glShadeModel(if (Minecraft.isAmbientOcclusionEnabled) GL_SMOOTH else GL_FLAT)
逐条说明:
| 操作 | 作用 |
|---|---|
ambientOcclusion = 0 |
临时关掉 AO(移动时 AO 会闪烁),结束时恢复 oldOcclusion |
enableLightmap / setLightmapTextureCoords |
手工设置光照贴图坐标,l1 = block light、l2 = sky light |
glColor4f(0, 0, 0, 0) |
alpha = 0 → 移动中的方块是半透明/幽灵态,这是最显眼的效果 |
glDisable(GL_CULL_FACE) |
关背面剔除,两面都画 |
glShadeModel |
有 AO 时用 GL_SMOOTH(平滑着色) |
TileEntityRendererDispatcher.instance.field_147553_e |
先 bindTexture(TextureMap.locationBlocksTexture) 绑定方块贴图图集 |
两遍渲染(for (pass <- 0 to 1))对应 block.getRenderBlockPass:
block.getRenderBlockPass
if (block.canRenderInPass(pass)) {
renderHack = false
renderBlocks.renderBlockByRenderType(block, x, y, z)
renderHack = true
}
tess.setTranslation(0, 0, 0)
tess.draw()
renderHack = false 夹住 renderBlockByRenderType 调用——ASM 改写后的 getRenderType 会检查这个标志,为 false 时不做任何特殊处理,从而避免重绘时再次触发"移动中返回 -1"的短路。翻译量是 -staticPlayerX + clamp(rpos.x) 这类形式(减去相机偏移)。
最后 RenderHelper.enableStandardItemLighting() / disableLightmap(frame) / 恢复 ambientOcclusion。
MovingRenderBlocks:微缩碰撞盒
class MovingRenderBlocks(w: IBlockAccess) extends RenderBlocks(w) {
val eps = 1.0 / 0x10000
override def renderStandardBlock(block: Block, x: Int, y: Int, z: Int) = {
if (MovementManager2.isMoving(Minecraft.getMinecraft.theWorld, x, y, z)) {
if (renderMinX == 0.0d) renderMinX += eps
if (renderMinY == 0.0d) renderMinY += eps
if (renderMinZ == 0.0d) renderMinZ += eps
if (renderMaxX == 1.0d) renderMaxX -= eps
if (renderMaxY == 1.0d) renderMaxY -= eps
if (renderMaxZ == 1.0d) renderMaxZ -= eps
}
super.renderStandardBlock(block, x, y, z)
}
}
eps = 1/65536。若某面的边界正好在 0.0 或 1.0(贴边),就往内收 eps。作用是消除 z-fighting:移动副本与原位静态方块共享同一面时会产生深度冲突,收缩一点点就能让两者共存而不闪烁。只收缩恰好等于 0/1 的边界,嵌在别处(如半砖)的方块不动。
MovingWorld:光照代理
MovingWorld 包装真实 World 实现 IBlockAccess,绝大多数方法直接转发。只覆写一个方法:
override def getLightBrightnessForSkyBlocks(x: Int, y: Int, z: Int, light: Int) =
MovementManager2.isMoving(Minecraft.getMinecraft.theWorld, x, y, z) match {
case true =>
val l1 = computeLightValue(x, y, z, EnumSkyBlock.Sky)
val l2 = computeLightValue(x, y, z, EnumSkyBlock.Block)
l1 << 20 | Seq(l2, light).max << 4
case false => world.getLightBrightnessForSkyBlocks(x, y, z, light)
}
def computeLightValue(x: Int, y: Int, z: Int, tpe: EnumSkyBlock) =
(for (s <- 0 until 6; c = new BlockCoord(x, y, z).offset(s))
yield world.getSavedLightValue(tpe, c.x, c.y, c.z)).max
移动中的方块取周围 6 格的 getSavedLightValue 最大值,而非原坐标的光照;sky 与 block 两套光分别取 max,再按原版位打包格式(l1 << 20 | l2 << 4)组装,light 参数与 block light 取 max 作为下限。
为什么需要:方块本体还在原位,但视觉上它已经滑到新位置附近——按新位置取光照才符合观感。
getBiomeGenForCoords 也被覆写为转发(IBlockAccess 接口要求)。除此之外 getBlock、getTileEntity、getBlockMetadata、isAirBlock、getHeight、extendedLevelsInChunkCache、isBlockProvidingPowerTo、isSideSolid 全部直接委托给 world。
ASMHacks:被 ASM 注入的两个静态方法
hacks.scala 里的 object ASMHacks 是 ASM 核心 mod 注入的落点,两个方法都是 @SideOnly(Side.CLIENT)。
def renderTileEntityAt(te: TileEntity, x: Double, y: Double, z: Double, tick: Float): Unit = {
if (MovementManager2.isMoving(te.getWorldObj, te.xCoord, te.yCoord, te.zCoord)) {
val s = MovementManager2.getEnclosedStructure(te.getWorldObj, te.xCoord, te.yCoord, te.zCoord)
val vec = MovingRenderer.renderPos(s, tick)
TileEntityRendererDispatcher.instance.renderTileEntityAt(te,
x + MathLib.clamp(-1f, 1f, vec.x.toFloat),
y + MathLib.clamp(-1f, 1f, vec.y.toFloat),
z + MathLib.clamp(-1f, 1f, vec.z.toFloat), tick)
} else TileEntityRendererDispatcher.instance.renderTileEntityAt(te, x, y, z, tick)
}
方块实体的渲染没有半透明重绘层——而是把整个 renderTileEntityAt 调用原地平移。判定依据是 TE 自己的坐标是否落在运动结构内。
def getRenderType(block: Block, x: Int, y: Int, z: Int): Int = {
if (MovingRenderer.renderHack && MovementManager2.isMoving(Minecraft.getMinecraft.theWorld, x, y, z)) -1
else block.getRenderType
}
返回 -1 即 RenderBlocks 的 “跳过渲染” 特殊值——移动中的方块在原版渲染路径里被整体跳过,因为它们已被 MovingRenderer 半透明重绘过。renderHack && 前置条件保证 MovingRenderer 自己触发的重绘不受影响。
相关条目
- ASM 核心 mod —— 上面两个方法是如何被注入原版渲染类的
- 运动模型(结构与行的分解) ——
isMoving/getEnclosedStructure/renderPos的数据来源 - 网络协议 —— 客户端的
BlockStruct从哪来 - 移动占位方块 —— 占位方块那一侧的碰撞与推实体