客户端渲染:移动中的方块如何画出来

结构移动期间方块本体还停在原位(直到动画结束才真正 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 自己触发的重绘不受影响。

相关条目