P8 完成:世界数据 → 官方渲染器 → 真实出图
不起完整客户端、不连服、不登录,只初始化 MC 渲染栈并喂入我们自己造的世界数据。
已验证(全绿):
countRenderedChunks=29 distinct colors=1450
画面:草地/原木/树叶/天空/HUD/英文文字(sample-world-render.png)
打通的关卡:
- 反混淆(client-named.jar)+ GL 离屏 + new Minecraft(cfg) 不进主循环
- 资源同步重载:22 listener(ModelManager/FontManager/Dispatcher)
★ 不能 join reloadResourcePacks(等主循环→死锁),直接调各 listener reload()
- 注册表从客户端自带 data/ 加载(dimension_type=4)
- 区块注入:LevelChunk + setBlockState → ClientChunkCache.Storage.chunks
+ LightEngine.queueSectionData(全亮)→ allChanged()
- 每帧 mc.tick() 推进网格重建队列(不跑则 countRenderedChunks=0)
踩坑记录(10 条,README 详列):
LoadingOverlay/ReceivingLevelScreen 遮挡、FolderData 参数序、
assetIndex 用落盘名、reloadShaders 包私有、读像素必须绑 FBO、
判据用 distinct colors 而非 non-black
更正附录 D:state_id=1003 → note_block 不是误映射(note_block 有 800 状态)
—— 真客户端给完整状态,是 1.20.1 上 mod 方块语义的唯一权威来源
This commit is contained in:
344
scaffold/WorldRender.java
Normal file
344
scaffold/WorldRender.java
Normal file
@ -0,0 +1,344 @@
|
||||
import com.mojang.blaze3d.pipeline.MainTarget;
|
||||
import com.mojang.blaze3d.platform.DisplayData;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.authlib.properties.PropertyMap;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.User;
|
||||
import net.minecraft.client.main.GameConfig;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import net.minecraft.client.telemetry.TelemetryEventSender;
|
||||
import net.minecraft.client.telemetry.WorldSessionTelemetryManager;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.util.profiling.InactiveProfiler;
|
||||
import net.minecraft.world.Difficulty;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import org.lwjgl.opengl.GL11;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.net.Proxy;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 世界渲染器 —— 把世界数据喂给真客户端渲染器并出图。
|
||||
*
|
||||
* 实测要点(2026-09-25):
|
||||
* 1. 不跑主循环 ⇒ 渲染栈手动初始化(tryDetectVersion/Bootstrap/RenderSystem/new Minecraft)
|
||||
* 2. 注册表从客户端自带 data/ 加载(RegistryDataLoader + vanilla pack)
|
||||
* 3. shader 需显式编译(GameRenderer.reloadShaders 包私有 → 同包 bridge)
|
||||
* 4. LoadingOverlay 必须摘掉,否则永远渲出加载屏
|
||||
* 5. 区块靠 LevelChunk + setBlockState 注入 + setSectionDirty 触发网格重建
|
||||
* 6. 读像素必须绑 FBO(glBindFramebuffer(frameBufferId)),GL_BACK 读到的是残留
|
||||
*/
|
||||
public class WorldRender {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
|
||||
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
|
||||
String assetIdx = arg(args, "--assetIndex", "1.20.1");
|
||||
String out = arg(args, "--out", "/tmp/scaffold-world.png");
|
||||
int W = Integer.parseInt(arg(args, "--width", "854"));
|
||||
int H = Integer.parseInt(arg(args, "--height", "480"));
|
||||
|
||||
SharedConstants.tryDetectVersion();
|
||||
SharedConstants.enableDataFixerOptimizations();
|
||||
Bootstrap.bootStrap();
|
||||
|
||||
File runDir = new File(gameDir);
|
||||
User user = new User("mcfold_cam", "0".repeat(32), "mcfold_cam",
|
||||
Optional.empty(), Optional.empty(), User.Type.LEGACY);
|
||||
PropertyMap props = new PropertyMap();
|
||||
GameConfig cfg = new GameConfig(
|
||||
new GameConfig.UserData(user, props, props, Proxy.NO_PROXY),
|
||||
new DisplayData(W, H, OptionalInt.empty(), OptionalInt.empty(), false),
|
||||
new GameConfig.FolderData(runDir, new File(runDir, "resourcepacks"), new File(assets), assetIdx),
|
||||
new GameConfig.GameData(false, "mcfold", "release", false, false),
|
||||
new GameConfig.QuickPlayData(null, null, null, null));
|
||||
|
||||
RenderSystem.initRenderThread();
|
||||
RenderSystem.beginInitialization();
|
||||
Minecraft mc = new Minecraft(cfg);
|
||||
RenderSystem.finishInitialization();
|
||||
System.out.println("[wr] Minecraft ok");
|
||||
|
||||
// ── 资源:字体/贴图/模型 ──
|
||||
// createReload 的 future 不该在主线程 join(会死锁),但字体加载是同步的,
|
||||
// 这里用官方 FontManager.reload 直接同步加载字体(修「文字成方框」)
|
||||
// ★ 资源重载:modelManager/fontManager/textureManager 全是 reload listener,
|
||||
// 不跑重载则方块模型/字体/贴图全空(地形渲不出来)。
|
||||
// 驱动方式照抄 LoadingOverlay:轮询 isDone(),不能 join(会死锁)。
|
||||
reloadResources(mc);
|
||||
System.out.println("[wr] resources reloaded");
|
||||
|
||||
// ── listener ──
|
||||
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
|
||||
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
|
||||
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
|
||||
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
|
||||
ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry);
|
||||
|
||||
// ── 注册表:从客户端自带 data/ 加载 ──
|
||||
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
|
||||
new File(runDir, "datapacks").toPath());
|
||||
packs.reload();
|
||||
ResourceManager dataRm = new net.minecraft.server.packs.resources.MultiPackResourceManager(
|
||||
net.minecraft.server.packs.PackType.SERVER_DATA,
|
||||
java.util.List.of(packs.getPack("vanilla").open()));
|
||||
java.util.List<RegistryDataLoader.RegistryData<?>> all = new java.util.ArrayList<>();
|
||||
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
|
||||
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
|
||||
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
|
||||
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
|
||||
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
|
||||
new RegistryAccess.Frozen[]{loaded});
|
||||
java.lang.reflect.Field rf = ClientPacketListener.class.getDeclaredField("registryAccess");
|
||||
rf.setAccessible(true);
|
||||
rf.set(listener, layered);
|
||||
System.out.println("[wr] registries loaded: dimension_type="
|
||||
+ loaded.registryOrThrow(Registries.DIMENSION_TYPE).size());
|
||||
|
||||
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
|
||||
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld")));
|
||||
|
||||
// ── ClientLevel ──
|
||||
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
|
||||
ClientLevel level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
|
||||
8, 8, () -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L);
|
||||
mc.setLevel(level);
|
||||
|
||||
var player = new net.minecraft.client.player.LocalPlayer(mc, level, listener,
|
||||
new net.minecraft.stats.StatsCounter(), new net.minecraft.client.ClientRecipeBook(), false, false);
|
||||
// 站在区块中心上方
|
||||
player.setPos(8.0, 70.0, 8.0);
|
||||
mc.player = player;
|
||||
mc.setCameraEntity(player);
|
||||
mc.gameMode = new net.minecraft.client.multiplayer.MultiPlayerGameMode(mc, listener);
|
||||
System.out.println("[wr] level + player ok");
|
||||
|
||||
// ── shader 编译(包私有,走同包 bridge)──
|
||||
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
|
||||
System.out.println("[wr] shaders ok: " + (RenderSystem.getShader() != null));
|
||||
|
||||
// ── LoadingOverlay 必须摘掉 ──
|
||||
mc.setOverlay(null);
|
||||
// ★ "Joining world..." 是 ReceivingLevelScreen;不清它客户端始终认为未进世界
|
||||
mc.setScreen(null);
|
||||
|
||||
// ── 注入区块 ──
|
||||
int injected = injectWorld(level, mc);
|
||||
System.out.println("[wr] injected chunks=" + injected);
|
||||
|
||||
// ── 第三人称(跳过 renderItemInHand)──
|
||||
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
|
||||
|
||||
// ── 渲染(每帧先 tick:区块网格重建队列靠 tick 推进)──
|
||||
MainTarget target = (MainTarget) mc.getMainRenderTarget();
|
||||
for (int f = 0; f < 60; f++) {
|
||||
try { mc.tick(); } catch (Throwable t) { if (f == 0) System.out.println("[wr] tick err: " + t); }
|
||||
RenderSystem.clear(16640, Minecraft.ON_OSX);
|
||||
target.bindWrite(true);
|
||||
mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, true);
|
||||
target.unbindWrite();
|
||||
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
|
||||
mc.getWindow().updateDisplay();
|
||||
}
|
||||
System.out.println("[wr] rendered 20 frames");
|
||||
|
||||
// ── 诊断:区块与网格状态 ──
|
||||
System.out.println("[wr] hasRenderedAllChunks=" + mc.levelRenderer.hasRenderedAllChunks()
|
||||
+ " countRenderedChunks=" + mc.levelRenderer.countRenderedChunks());
|
||||
var got = level.getChunkSource().getChunk(0, 0, net.minecraft.world.level.chunk.ChunkStatus.FULL, false);
|
||||
System.out.println("[wr] getChunk(0,0)=" + (got != null ? got.getClass().getSimpleName() : "null")
|
||||
+ " block(0,64,0)=" + (got != null ? got.getBlockState(new BlockPos(0,64,0)) : "-"));
|
||||
System.out.println("[wr] isChunkCompiled(0,64,0)=" + mc.levelRenderer.isChunkCompiled(new BlockPos(0,64,0)));
|
||||
|
||||
save(target, W, H, out);
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步驱动各 reload listener。
|
||||
*
|
||||
* 为什么不用 Minecraft.reloadResourcePacks():
|
||||
* 它的 future 靠主循环 tick 推进(LoadingOverlay),我们不跑主循环 ⇒ 死锁。
|
||||
* 为什么不用 createReload():
|
||||
* prepare 阶段仍走真后台池,实测 120s 超时。
|
||||
* 正解:直接调各 listener 的 public reload(),barrier 同步返回,
|
||||
* 且 apply 用 Runnable::run 保证在渲染线程执行。
|
||||
*/
|
||||
private static void reloadResources(Minecraft mc) throws Exception {
|
||||
var rmF = Minecraft.class.getDeclaredField("resourceManager");
|
||||
rmF.setAccessible(true);
|
||||
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
|
||||
var packs = mc.getResourcePackRepository();
|
||||
packs.reload();
|
||||
|
||||
// 同步 barrier:prepare 完成后立刻交出,不排队
|
||||
var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() {
|
||||
@Override public <T> java.util.concurrent.CompletableFuture<T> wait(T t) {
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(t);
|
||||
}
|
||||
};
|
||||
var prof = InactiveProfiler.INSTANCE;
|
||||
// prepare 用当前线程(同步跑),apply 也用当前线程(渲染线程)
|
||||
java.util.concurrent.Executor sync = Runnable::run;
|
||||
|
||||
var all = reloadListeners(mc);
|
||||
System.out.println("[wr] listeners=" + all.size());
|
||||
for (var listener : all) {
|
||||
String name = listener.getName();
|
||||
System.out.println("[wr] -> " + name + " ...");
|
||||
try {
|
||||
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
|
||||
boolean done = false;
|
||||
long dl = System.currentTimeMillis() + 30_000;
|
||||
while (!done && System.currentTimeMillis() < dl) {
|
||||
done = fut.isDone();
|
||||
if (!done) Thread.sleep(10);
|
||||
}
|
||||
if (!done) { System.out.println("[wr] TIMEOUT " + name); continue; }
|
||||
fut.join();
|
||||
System.out.println("[wr] ok: " + name);
|
||||
} catch (Throwable e) {
|
||||
System.out.println("[wr] FAILED " + name + ": " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 取出注册过的 listener(private 字段) */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Minecraft mc) throws Exception {
|
||||
var rmF = Minecraft.class.getDeclaredField("resourceManager");
|
||||
rmF.setAccessible(true);
|
||||
var rm = rmF.get(mc);
|
||||
for (var f : rm.getClass().getDeclaredFields()) {
|
||||
if (java.util.List.class.isAssignableFrom(f.getType())) {
|
||||
f.setAccessible(true);
|
||||
Object v = f.get(rm);
|
||||
if (v instanceof java.util.List<?> l && !l.isEmpty()
|
||||
&& l.get(0) instanceof net.minecraft.server.packs.resources.PreparableReloadListener) {
|
||||
return (java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener>) l;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("reload listeners not found");
|
||||
}
|
||||
|
||||
/** 造地形并注入:区块 + 光照 + 网格重建 */
|
||||
private static int injectWorld(ClientLevel level, Minecraft mc) throws Exception {
|
||||
int radius = 4; // 9x9 区块 = 144x144 格
|
||||
int yBottom = level.getMinBuildHeight();
|
||||
var cache = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
|
||||
var light = cache.getLightEngine();
|
||||
|
||||
// 反射拿 Storage.chunks / getIndex(包私有)
|
||||
var storageF = net.minecraft.client.multiplayer.ClientChunkCache.class.getDeclaredField("storage");
|
||||
storageF.setAccessible(true);
|
||||
Object storage = storageF.get(cache);
|
||||
var chunksF = storage.getClass().getDeclaredField("chunks");
|
||||
chunksF.setAccessible(true);
|
||||
var chunks = (java.util.concurrent.atomic.AtomicReferenceArray<LevelChunk>) chunksF.get(storage);
|
||||
var getIndex = storage.getClass().getDeclaredMethod("getIndex", int.class, int.class);
|
||||
getIndex.setAccessible(true);
|
||||
System.out.println("[wr] storage chunks.length=" + chunks.length());
|
||||
|
||||
int count = 0;
|
||||
for (int cx = -radius; cx <= radius; cx++) {
|
||||
for (int cz = -radius; cz <= radius; cz++) {
|
||||
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
|
||||
for (int lx = 0; lx < 16; lx++) {
|
||||
for (int lz = 0; lz < 16; lz++) {
|
||||
int wx = cx * 16 + lx, wz = cz * 16 + lz;
|
||||
for (int y = yBottom; y < 63; y++)
|
||||
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.STONE.defaultBlockState(), false);
|
||||
chunk.setBlockState(new BlockPos(lx, 63, lz), Blocks.DIRT.defaultBlockState(), false);
|
||||
chunk.setBlockState(new BlockPos(lx, 64, lz), Blocks.GRASS_BLOCK.defaultBlockState(), false);
|
||||
if ((wx % 7 == 0 && wz % 7 == 0) && (wx != 0 || wz != 0)) {
|
||||
for (int y = 65; y < 72; y++)
|
||||
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.OAK_LOG.defaultBlockState(), false);
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dz = -2; dz <= 2; dz++)
|
||||
if (lx+dx >= 0 && lx+dx < 16 && lz+dz >= 0 && lz+dz < 16
|
||||
&& Math.abs(dx) + Math.abs(dz) <= 2)
|
||||
chunk.setBlockState(new BlockPos(lx+dx, 72, lz+dz),
|
||||
Blocks.OAK_LEAVES.defaultBlockState(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ★ 核心:直接放进 ClientChunkCache.Storage.chunks
|
||||
int idx = (int) getIndex.invoke(storage, cx, cz);
|
||||
chunks.set(idx, chunk);
|
||||
count++;
|
||||
|
||||
// ★ 光照:全亮(否则地形全黑)。section y 0..19 覆盖 build height
|
||||
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
|
||||
var sky = new net.minecraft.world.level.chunk.DataLayer();
|
||||
for (int x = 0; x < 16; x++)
|
||||
for (int y = 0; y < 16; y++)
|
||||
for (int z = 0; z < 16; z++) sky.set(x, y, z, 15);
|
||||
light.queueSectionData(net.minecraft.world.level.LightLayer.SKY,
|
||||
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||||
light.queueSectionData(net.minecraft.world.level.LightLayer.BLOCK,
|
||||
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||||
}
|
||||
light.setLightEnabled(new ChunkPos(cx, cz), true);
|
||||
chunk.setLightCorrect(true);
|
||||
}
|
||||
}
|
||||
int upd = light.runLightUpdates();
|
||||
System.out.println("[wr] light updates=" + upd);
|
||||
|
||||
// 触发区块网格重建
|
||||
mc.levelRenderer.allChanged();
|
||||
System.out.println("[wr] allChanged done");
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void save(MainTarget target, int W, int H, String out) throws Exception {
|
||||
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, target.frameBufferId);
|
||||
GL11.glReadBuffer(org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0);
|
||||
var buf = org.lwjgl.BufferUtils.createByteBuffer(W * H * 4);
|
||||
GL11.glReadPixels(0, 0, W, H, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf);
|
||||
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, 0);
|
||||
|
||||
BufferedImage img = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
|
||||
for (int y = 0; y < H; y++)
|
||||
for (int x = 0; x < W; x++) {
|
||||
int i = ((H - 1 - y) * W + x) * 4;
|
||||
img.setRGB(x, y, ((buf.get(i) & 0xFF) << 16) | ((buf.get(i+1) & 0xFF) << 8) | (buf.get(i+2) & 0xFF));
|
||||
}
|
||||
ImageIO.write(img, "png", new File(out));
|
||||
java.util.Set<Integer> colors = new java.util.HashSet<>();
|
||||
for (int p : img.getRGB(0, 0, W, H, null, 0, W)) colors.add(p & 0xFFFFFF);
|
||||
System.out.println("[wr] wrote " + out + " distinct colors=" + colors.size());
|
||||
}
|
||||
|
||||
private static String arg(String[] a, String key, String def) {
|
||||
for (int i = 0; i < a.length - 1; i++) if (a[i].equals(key)) return a[i + 1];
|
||||
return def;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user