From 18f22f57d0b38133e3b6c336034ff68df9581f52 Mon Sep 17 00:00:00 2001 From: johni0702 Date: Sat, 23 May 2015 14:56:11 +0200 Subject: [PATCH] Rework rendering Adds default, stereoscopic, tiling, cubic and equirectangular frame rendering --- .../eu/crushedpixel/replaymod/ReplayMod.java | 106 ++++- .../replaymod/coremod/CameraRollCT.java | 18 +- .../replaymod/events/GuiEventHandler.java | 7 - .../replaymod/events/GuiReplayOverlay.java | 23 +- .../events/TickAndRenderListener.java | 9 +- .../replaymod/gui/GuiVideoRenderer.java | 95 +++++ .../interpolation/Interpolation.java | 7 + .../interpolation/LinearInterpolation.java | 7 +- .../replaymod/interpolation/SplinePoint.java | 5 +- .../renderer/ChunkLoadingRenderGlobal.java | 110 +++++ .../replaymod/replay/ReplayHandler.java | 13 +- .../replaymod/replay/ReplayProcess.java | 118 ++---- .../replaymod/settings/RenderOptions.java | 68 +++ .../replaymod/timer/MCTimerHandler.java | 4 + .../replaymod/utils/JailingQueue.java | 120 ++++++ .../replaymod/video/ReplayTimer.java | 3 - .../replaymod/video/VideoRenderer.java | 391 ++++++++++++++++++ .../replaymod/video/VideoWriter.java | 214 ++++++---- .../video/entity/CubicEntityRenderer.java | 109 +++++ .../video/entity/CustomEntityRenderer.java | 225 ++++++++++ .../entity/StereoscopicEntityRenderer.java | 39 ++ .../video/entity/TilingEntityRenderer.java | 41 ++ .../video/frame/CubicFrameRenderer.java | 79 ++++ .../video/frame/DefaultFrameRenderer.java | 27 ++ .../frame/EquirectangularFrameRenderer.java | 101 +++++ .../replaymod/video/frame/FrameRenderer.java | 225 ++++++++++ .../frame/StereoscopicFrameRenderer.java | 51 +++ .../video/frame/TilingFrameRenderer.java | 130 ++++++ src/main/resources/META-INF/replaymod_at.cfg | 29 ++ src/main/resources/assets/replaymod/logo.jpg | Bin 0 -> 27743 bytes 30 files changed, 2167 insertions(+), 207 deletions(-) create mode 100644 src/main/java/eu/crushedpixel/replaymod/gui/GuiVideoRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/interpolation/Interpolation.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/renderer/ChunkLoadingRenderGlobal.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/utils/JailingQueue.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/entity/CubicEntityRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/entity/CustomEntityRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/entity/StereoscopicEntityRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/entity/TilingEntityRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/CubicFrameRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/DefaultFrameRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/EquirectangularFrameRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/FrameRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/StereoscopicFrameRenderer.java create mode 100644 src/main/java/eu/crushedpixel/replaymod/video/frame/TilingFrameRenderer.java create mode 100755 src/main/resources/assets/replaymod/logo.jpg diff --git a/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java b/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java index b967620e..7a9be80d 100755 --- a/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java +++ b/src/main/java/eu/crushedpixel/replaymod/ReplayMod.java @@ -1,8 +1,10 @@ package eu.crushedpixel.replaymod; +import com.google.common.util.concurrent.ListenableFutureTask; import eu.crushedpixel.replaymod.api.ApiClient; import eu.crushedpixel.replaymod.chat.ChatMessageHandler; import eu.crushedpixel.replaymod.events.*; +import eu.crushedpixel.replaymod.holders.KeyframeSet; import eu.crushedpixel.replaymod.localization.LocalizedResourcePack; import eu.crushedpixel.replaymod.online.authentication.AuthenticationHandler; import eu.crushedpixel.replaymod.recording.ConnectionEventHandler; @@ -10,10 +12,14 @@ import eu.crushedpixel.replaymod.reflection.MCPNames; import eu.crushedpixel.replaymod.registry.*; import eu.crushedpixel.replaymod.renderer.InvisibilityRender; import eu.crushedpixel.replaymod.renderer.SafeEntityRenderer; +import eu.crushedpixel.replaymod.replay.ReplayHandler; +import eu.crushedpixel.replaymod.replay.ReplayProcess; import eu.crushedpixel.replaymod.replay.ReplaySender; +import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.settings.ReplaySettings; import eu.crushedpixel.replaymod.utils.ReplayFile; import eu.crushedpixel.replaymod.utils.ReplayFileIO; +import eu.crushedpixel.replaymod.video.frame.*; import net.minecraft.client.Minecraft; import net.minecraft.client.settings.GameSettings; import net.minecraftforge.common.MinecraftForge; @@ -28,8 +34,10 @@ import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; import org.apache.commons.io.FilenameUtils; import java.io.File; +import java.io.IOException; import java.lang.reflect.Field; import java.util.List; +import java.util.Queue; @Mod(modid = ReplayMod.MODID, version = ReplayMod.VERSION) public class ReplayMod { @@ -116,7 +124,7 @@ public class ReplayMod { } @EventHandler - public void postInit(FMLPostInitializationEvent event) { + public void postInit(FMLPostInitializationEvent event) throws IOException { GameSettings.Options.RENDER_DISTANCE.setValueMax(64f); overlay = new GuiReplayOverlay(); @@ -164,6 +172,102 @@ public class ReplayMod { FMLCommonHandler.instance().exitJava(0, false); } */ + + if (System.getProperty("replaymod.render.file") != null) { + final File file = new File(System.getProperty("replaymod.render.file")); + if (!file.exists()) { + throw new IOException("File \"" + file.getPath() + "\" not found."); + } + + String renderDistance = System.getProperty("mc.renderdistance"); + if (renderDistance != null) { + mc.gameSettings.renderDistanceChunks = Integer.parseInt(renderDistance); + } + + final String path = System.getProperty("replaymod.render.path"); + String type = System.getProperty("replaymod.render.type"); + String quality = System.getProperty("replaymod.render.quality"); + String fps = System.getProperty("replaymod.render.fps"); + String waitForChunks = System.getProperty("replaymod.render.waitforchunks"); + String linearMovement = System.getProperty("replaymod.render.linearmovement"); + FrameRenderer renderer; + if (type != null) { + String[] parts = type.split(":"); + type = parts[0].toUpperCase(); + if ("NORMAL".equals(type) || "DEFAULT".equals(type)) { + renderer = new DefaultFrameRenderer(); + } else if ("TILED".equals(type)) { + if (parts.length < 3) { + throw new IllegalArgumentException("Tiled renderer requires width and height."); + } + int width = Integer.parseInt(parts[1]); + int height = Integer.parseInt(parts[2]); + renderer = new TilingFrameRenderer(width, height); + } else if ("STEREO".equals(type) || "STEREOSCOPIC".equals(type)) { + renderer = new StereoscopicFrameRenderer(); + } else if ("CUBIC".equals(type)) { + if (parts.length < 2) { + throw new IllegalArgumentException("Cubic renderer requires boolean for whether it's stable."); + } + renderer = new CubicFrameRenderer(Boolean.parseBoolean(parts[1])); + } else if ("EQUIRECTANGULAR".equals(type)) { + if (parts.length < 2) { + throw new IllegalArgumentException("Equirectangular renderer requires boolean for whether it's stable."); + } + renderer = new EquirectangularFrameRenderer(Boolean.parseBoolean(parts[1])); + } else { + throw new IllegalArgumentException("Unknown type: " + parts[0]); + } + } else { + renderer = new DefaultFrameRenderer(); + } + final RenderOptions options = new RenderOptions(renderer); + if (quality != null) { + options.setQuality(Float.parseFloat(quality)); + } + if (fps != null) { + options.setFps(Integer.parseInt(fps)); + } + if (waitForChunks != null) { + options.setWaitForChunks(Boolean.parseBoolean(waitForChunks)); + } + if (linearMovement != null) { + options.setLinearMovement(Boolean.parseBoolean(linearMovement)); + } + + @SuppressWarnings("unchecked") + Queue tasks = mc.scheduledTasks; + synchronized (mc.scheduledTasks) { + tasks.add(ListenableFutureTask.create(new Runnable() { + @Override + public void run() { + System.out.println("Loading replay..."); + ReplayHandler.startReplay(file, false); + + int index = 0; + if (path != null) { + for (KeyframeSet set : ReplayHandler.getKeyframeRepository()) { + if (set.getName().equals(path)) { + break; + } + index++; + } + if (index >= ReplayHandler.getKeyframeRepository().length) { + throw new IllegalArgumentException("No path named \"" + path + "\"."); + } + } else if (ReplayHandler.getKeyframeRepository().length == 0) { + throw new IllegalArgumentException("Replay file has no paths defined."); + } + ReplayHandler.useKeyframePresetFromRepository(index); + + System.out.println("Rendering started..."); + ReplayProcess.startReplayProcess(options); + System.out.println("Rendering done. Shutting down..."); + mc.shutdown(); + } + }, null)); + } + } } private void removeTmcprFiles() { diff --git a/src/main/java/eu/crushedpixel/replaymod/coremod/CameraRollCT.java b/src/main/java/eu/crushedpixel/replaymod/coremod/CameraRollCT.java index 710be795..b09310fe 100644 --- a/src/main/java/eu/crushedpixel/replaymod/coremod/CameraRollCT.java +++ b/src/main/java/eu/crushedpixel/replaymod/coremod/CameraRollCT.java @@ -30,8 +30,22 @@ public class CameraRollCT implements IClassTransformer { boolean success = false; for (MethodNode m : classNode.methods) { if ("(F)V".equals(m.desc) && name_orientCamera.equals(m.name)) { - inject(m.instructions.iterator()); - success = true; + ListIterator iter = m.instructions.iterator(); + int f = 0; + while (iter.hasNext()) { + AbstractInsnNode node = iter.next(); + if ((f == 0 || f == 1) && node.getOpcode() == FCONST_0) { + f++; + } else if ((f == 2) && node instanceof LdcInsnNode && ((LdcInsnNode) node).cst.equals(-0.1f)) { + f++; + } else if (f == 3) { + inject(iter); + success = true; + break; + } else { + f = 0; + } + } } } if (!success) { diff --git a/src/main/java/eu/crushedpixel/replaymod/events/GuiEventHandler.java b/src/main/java/eu/crushedpixel/replaymod/events/GuiEventHandler.java index d7267de3..d9a01eac 100755 --- a/src/main/java/eu/crushedpixel/replaymod/events/GuiEventHandler.java +++ b/src/main/java/eu/crushedpixel/replaymod/events/GuiEventHandler.java @@ -1,7 +1,6 @@ package eu.crushedpixel.replaymod.events; import eu.crushedpixel.replaymod.ReplayMod; -import eu.crushedpixel.replaymod.gui.GuiCancelRender; import eu.crushedpixel.replaymod.gui.GuiConstants; import eu.crushedpixel.replaymod.gui.GuiReplaySaving; import eu.crushedpixel.replaymod.gui.GuiReplaySettings; @@ -18,7 +17,6 @@ import eu.crushedpixel.replaymod.studio.VersionValidator; import eu.crushedpixel.replaymod.timer.MCTimerHandler; import eu.crushedpixel.replaymod.utils.MouseUtils; import eu.crushedpixel.replaymod.utils.ReplayFileIO; -import eu.crushedpixel.replaymod.video.VideoWriter; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.*; import net.minecraft.client.gui.inventory.GuiInventory; @@ -52,11 +50,6 @@ public class GuiEventHandler { @SubscribeEvent public void onGui(GuiOpenEvent event) { - if(VideoWriter.isRecording() && !(event.gui instanceof GuiCancelRender)) { - event.gui = null; - return; - } - if(event.gui instanceof GuiMainMenu) { if(ReplayMod.firstMainMenu) { ReplayMod.firstMainMenu = false; diff --git a/src/main/java/eu/crushedpixel/replaymod/events/GuiReplayOverlay.java b/src/main/java/eu/crushedpixel/replaymod/events/GuiReplayOverlay.java index 99cbc537..bd414c10 100755 --- a/src/main/java/eu/crushedpixel/replaymod/events/GuiReplayOverlay.java +++ b/src/main/java/eu/crushedpixel/replaymod/events/GuiReplayOverlay.java @@ -11,8 +11,10 @@ import eu.crushedpixel.replaymod.recording.ConnectionEventHandler; import eu.crushedpixel.replaymod.registry.ReplayGuiRegistry; import eu.crushedpixel.replaymod.replay.ReplayHandler; import eu.crushedpixel.replaymod.replay.ReplayProcess; +import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.utils.MouseUtils; -import eu.crushedpixel.replaymod.video.VideoWriter; +import eu.crushedpixel.replaymod.video.frame.FrameRenderer; +import eu.crushedpixel.replaymod.video.frame.DefaultFrameRenderer; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Gui; import net.minecraft.client.gui.GuiChat; @@ -123,9 +125,8 @@ public class GuiReplayOverlay extends Gui { @SubscribeEvent public void onRenderGui(RenderGameOverlayEvent.Post event) throws IllegalArgumentException, IllegalAccessException { - if(!ReplayHandler.isInReplay() || FMLClientHandler.instance().isGUIOpen(GuiPlayerOverview.class) - || VideoWriter.isRecording() || FMLClientHandler.instance().isGUIOpen(GuiKeyframeRepository.class)) { + || FMLClientHandler.instance().isGUIOpen(GuiKeyframeRepository.class)) { return; } @@ -185,7 +186,19 @@ public class GuiReplayOverlay extends Gui { if(hover) { playOrPause(); } else if(mouseX >= exportButtonX && mouseX <= exportButtonX + 20 && mouseY >= exportButtonY && exportButtonY <= exportButtonY + 20) { - ReplayHandler.startPath(true); + // TODO Put all those settings into a nice little GUI + FrameRenderer renderer = new DefaultFrameRenderer(); +// FrameRenderer renderer = new TilingReadPixelFrameRenderer(mc.displayWidth*3, mc.displayHeight*2); +// FrameRenderer renderer = new TilingReadPixelFrameRenderer(3840, 2160); +// FrameRenderer renderer = new StereoscopicFrameRenderer(); +// FrameRenderer renderer = new CubicFrameRenderer(false); +// FrameRenderer renderer = new EquirectangularFrameRenderer(false); + RenderOptions options = new RenderOptions(renderer); + options.setLinearMovement(ReplayMod.replaySettings.isLinearMovement()); + options.setWaitForChunks(ReplayMod.replaySettings.getWaitForChunks()); + options.setFps(ReplayMod.replaySettings.getVideoFramerate()); + options.setQuality((float) ReplayMod.replaySettings.getVideoQuality()); + ReplayHandler.startPath(options); } if(mouseX >= timelineX + 4 && mouseX <= width - 18 && mouseY >= 11 && mouseY <= 29) { @@ -743,7 +756,7 @@ public class GuiReplayOverlay extends Gui { if(ReplayHandler.isInPath()) { ReplayHandler.interruptReplay(); } else { - ReplayHandler.startPath(false); + ReplayHandler.startPath(null); } } diff --git a/src/main/java/eu/crushedpixel/replaymod/events/TickAndRenderListener.java b/src/main/java/eu/crushedpixel/replaymod/events/TickAndRenderListener.java index 63a8b3ce..7376a79d 100644 --- a/src/main/java/eu/crushedpixel/replaymod/events/TickAndRenderListener.java +++ b/src/main/java/eu/crushedpixel/replaymod/events/TickAndRenderListener.java @@ -2,8 +2,6 @@ package eu.crushedpixel.replaymod.events; import eu.crushedpixel.replaymod.ReplayMod; import eu.crushedpixel.replaymod.chat.ChatMessageHandler; -import eu.crushedpixel.replaymod.gui.GuiCancelRender; -import eu.crushedpixel.replaymod.gui.GuiMouseInput; import eu.crushedpixel.replaymod.replay.ReplayHandler; import eu.crushedpixel.replaymod.replay.ReplayProcess; import eu.crushedpixel.replaymod.video.ReplayScreenshot; @@ -40,6 +38,7 @@ public class TickAndRenderListener { public void onRenderWorld(RenderWorldLastEvent event) throws InvocationTargetException, IOException, IllegalAccessException, IllegalArgumentException { if(!ReplayHandler.isInReplay()) return; //If not in Replay, cancel + if (ReplayProcess.isVideoRecording()) return; // If recording, cancel if(requestScreenshot == 1) { mc.addScheduledTask(new Runnable() { @@ -77,16 +76,12 @@ public class TickAndRenderListener { @SubscribeEvent public void tick(TickEvent event) { - if(!ReplayHandler.isInReplay()) return; + if(!ReplayHandler.isInReplay() || ReplayProcess.isVideoRecording()) return; if(ReplayHandler.getCameraEntity() != null) ReplayHandler.getCameraEntity().updateMovement(); if(ReplayHandler.isInPath()) { ReplayProcess.unblockAndTick(true); - if(ReplayProcess.isVideoRecording() && - !(mc.currentScreen instanceof GuiMouseInput || mc.currentScreen instanceof GuiCancelRender)) { - mc.displayGuiScreen(new GuiMouseInput()); - } } else onMouseMove(new MouseEvent()); FMLCommonHandler.instance().bus().post(new InputEvent.KeyInputEvent()); diff --git a/src/main/java/eu/crushedpixel/replaymod/gui/GuiVideoRenderer.java b/src/main/java/eu/crushedpixel/replaymod/gui/GuiVideoRenderer.java new file mode 100644 index 00000000..db7674a9 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/gui/GuiVideoRenderer.java @@ -0,0 +1,95 @@ +package eu.crushedpixel.replaymod.gui; + +import eu.crushedpixel.replaymod.video.VideoRenderer; +import eu.crushedpixel.replaymod.video.frame.FrameRenderer; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiScreen; +import net.minecraftforge.fml.client.config.GuiCheckBox; + +import java.io.IOException; + +public class GuiVideoRenderer extends GuiScreen { + private final VideoRenderer renderer; + + private final String PAUSE_RENDERING = "Pause Rendering"; + private final String RESUME_RENDERING = "Resume Rendering"; + private final String CANCEL = "Cancel Rendering"; + private final String CANCEL_CONFIRM = "Sure?"; + + private GuiButton pauseButton; + private GuiButton cancelButton; + private GuiCheckBox previewCheckBox; + + public GuiVideoRenderer(VideoRenderer renderer) { + this.renderer = renderer; + } + + @Override + @SuppressWarnings("unchecked") // I blame forge for not re-adding generics to that list + public void initGui() { + FrameRenderer frameRenderer = renderer.getFrameRenderer(); + + String text = "Show Preview (Performance might suffer)"; + buttonList.add(previewCheckBox = new GuiCheckBox(0, (width - fontRendererObj.getStringWidth(text)) / 2 - 8, + frameRenderer.getPreviewHeight(this), text, false)); + + text = PAUSE_RENDERING; + buttonList.add(pauseButton = new GuiButton(1, width / 2 - 202, height / 2 - 23, text)); + + text = CANCEL; + buttonList.add(cancelButton = new GuiButton(1, width / 2 + 2, height / 2 - 23, text)); + } + + @Override + protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException { + String cancelBefore = cancelButton.displayString; + super.mouseClicked(mouseX, mouseY, mouseButton); + String cancelAfter = cancelButton.displayString; + if (cancelBefore.equals(cancelAfter)) { + cancelButton.displayString = CANCEL; + } + } + + @Override + protected void actionPerformed(GuiButton button) throws IOException { + if (button == pauseButton) { + if (PAUSE_RENDERING.equals(pauseButton.displayString)) { + renderer.setPaused(true); + pauseButton.displayString = RESUME_RENDERING; + } else if (RESUME_RENDERING.equals(pauseButton.displayString)) { + renderer.setPaused(false); + pauseButton.displayString = PAUSE_RENDERING; + } else { + throw new IllegalStateException(pauseButton.displayString); + } + } else if (button == cancelButton) { + if (CANCEL.equals(cancelButton.displayString)) { + cancelButton.displayString = CANCEL_CONFIRM; + } else if (CANCEL_CONFIRM.equals(cancelButton.displayString)) { + renderer.cancel(); + } + } else if (button == previewCheckBox) { + renderer.getFrameRenderer().setPreviewActive(previewCheckBox.isChecked()); + } + } + + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + FrameRenderer frameRenderer = renderer.getFrameRenderer(); + int centerX = width / 2; + int centerY = height / 2; + + drawBackground(0); + + String framesProgress = String.format("Frames rendered: %d / %d", renderer.getFramesDone(), renderer.getTotalFrames()); + drawCenteredString(fontRendererObj, framesProgress, centerX, centerY - 45, 0xffffffff); + + if (previewCheckBox.isChecked()) { + frameRenderer.renderPreview(this); + } else { + int previewHeight = frameRenderer.getPreviewHeight(this) - height / 2 - 10; + FrameRenderer.renderNoPreview(width, height, previewHeight); + } + super.drawScreen(mouseX, mouseY, partialTicks); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/interpolation/Interpolation.java b/src/main/java/eu/crushedpixel/replaymod/interpolation/Interpolation.java new file mode 100644 index 00000000..9c0a63b7 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/interpolation/Interpolation.java @@ -0,0 +1,7 @@ +package eu.crushedpixel.replaymod.interpolation; + +public interface Interpolation { + void prepare(); + T getPoint(float position); + void addPoint(T pos); +} diff --git a/src/main/java/eu/crushedpixel/replaymod/interpolation/LinearInterpolation.java b/src/main/java/eu/crushedpixel/replaymod/interpolation/LinearInterpolation.java index 0bbfeb28..ae4b0c6f 100755 --- a/src/main/java/eu/crushedpixel/replaymod/interpolation/LinearInterpolation.java +++ b/src/main/java/eu/crushedpixel/replaymod/interpolation/LinearInterpolation.java @@ -5,7 +5,7 @@ import akka.japi.Pair; import java.util.ArrayList; import java.util.List; -public abstract class LinearInterpolation { +public abstract class LinearInterpolation implements Interpolation { protected List points = new ArrayList(); @@ -13,6 +13,11 @@ public abstract class LinearInterpolation { points = new ArrayList(); } + @Override + public void prepare() { + + } + public abstract K getPoint(float position); public void addPoint(K point) { diff --git a/src/main/java/eu/crushedpixel/replaymod/interpolation/SplinePoint.java b/src/main/java/eu/crushedpixel/replaymod/interpolation/SplinePoint.java index fb98f0f5..9a3539d0 100755 --- a/src/main/java/eu/crushedpixel/replaymod/interpolation/SplinePoint.java +++ b/src/main/java/eu/crushedpixel/replaymod/interpolation/SplinePoint.java @@ -6,7 +6,7 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.Vector; -public class SplinePoint extends BasicSpline { +public class SplinePoint extends BasicSpline implements Interpolation { private static final Object[] EMPTYOBJ = new Object[]{}; private Vector points; private Vector xCubics; @@ -60,7 +60,8 @@ public class SplinePoint extends BasicSpline { return points; } - public void calcSpline() { + @Override + public void prepare() { try { calcNaturalCubic(points, vectorX, xCubics); calcNaturalCubic(points, vectorY, yCubics); diff --git a/src/main/java/eu/crushedpixel/replaymod/renderer/ChunkLoadingRenderGlobal.java b/src/main/java/eu/crushedpixel/replaymod/renderer/ChunkLoadingRenderGlobal.java new file mode 100644 index 00000000..20be8dae --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/renderer/ChunkLoadingRenderGlobal.java @@ -0,0 +1,110 @@ +package eu.crushedpixel.replaymod.renderer; + +import eu.crushedpixel.replaymod.utils.JailingQueue; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.RegionRenderCacheBuilder; +import net.minecraft.client.renderer.RenderGlobal; +import net.minecraft.client.renderer.chunk.ChunkCompileTaskGenerator; +import net.minecraft.client.renderer.chunk.ChunkRenderDispatcher; +import net.minecraft.client.renderer.chunk.ChunkRenderWorker; +import net.minecraft.client.renderer.chunk.RenderChunk; +import net.minecraft.client.renderer.culling.ICamera; +import net.minecraft.entity.Entity; +import net.minecraft.util.BlockPos; + +import java.util.Iterator; +import java.util.concurrent.BlockingQueue; + +public class ChunkLoadingRenderGlobal extends RenderGlobal { + public static void install(Minecraft mc) { + RenderGlobal org = mc.renderGlobal; + if (org instanceof ChunkLoadingRenderGlobal) { + ((ChunkLoadingRenderGlobal) org).uninstall(); + } + mc.renderGlobal = new ChunkLoadingRenderGlobal(mc); + } + + private final RenderGlobal originalRenderGlobal; + private final JailingQueue workerJailingQueue; + private final CustomChunkRenderWorker renderWorker; + private int frame; + + @SuppressWarnings("unchecked") + private ChunkLoadingRenderGlobal(Minecraft mc) { + super(mc); + this.originalRenderGlobal = mc.renderGlobal; + this.renderWorker = new CustomChunkRenderWorker(renderDispatcher, new RegionRenderCacheBuilder()); + if (mc.theWorld != null) { + setWorldAndLoadRenderers(mc.theWorld); + } + + int workerThreads = renderDispatcher.listThreadedWorkers.size(); + BlockingQueue queueChunkUpdates = renderDispatcher.queueChunkUpdates; + workerJailingQueue = new JailingQueue(queueChunkUpdates); + renderDispatcher.queueChunkUpdates = workerJailingQueue; + ChunkCompileTaskGenerator element = new ChunkCompileTaskGenerator(null, null); + element.finish(); + for (int i = 0; i < workerThreads; i++) { + queueChunkUpdates.add(element); + } + workerJailingQueue.jail(workerThreads); + renderDispatcher.queueChunkUpdates = queueChunkUpdates; + } + + @Override + public void setupTerrain(Entity viewEntity, double partialTicks, ICamera camera, int frameCount, boolean playerSpectator) { + // There has to be a better way to force minecraft into queueing all chunks at once + // Someone should probably find and implement it! + do { + super.setupTerrain(viewEntity, partialTicks, camera, frame++, playerSpectator); + } while (displayListEntitiesDirty); + } + + @Override + public boolean isPositionInRenderChunk(BlockPos p_174983_1_, RenderChunk p_174983_2_) { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public void updateChunks(long finishTimeNano) { + while (renderDispatcher.runChunkUploads(0)) { + displayListEntitiesDirty = true; + } + + while (!renderDispatcher.queueChunkUpdates.isEmpty()) { + try { + renderWorker.processTask((ChunkCompileTaskGenerator) renderDispatcher.queueChunkUpdates.poll()); + } catch (InterruptedException ignored) { } + } + + Iterator iterator = chunksToUpdate.iterator(); + while (iterator.hasNext()) { + RenderChunk renderchunk = iterator.next(); + + renderDispatcher.updateChunkNow(renderchunk); + + renderchunk.setNeedsUpdate(false); + iterator.remove(); + } + } + + public void uninstall() { + workerJailingQueue.freeAll(); + Minecraft.getMinecraft().renderGlobal = originalRenderGlobal; + } + + /** + * Custom ChunkRenderWorker class providing access to the protected processTask method + */ + private static class CustomChunkRenderWorker extends ChunkRenderWorker { + public CustomChunkRenderWorker(ChunkRenderDispatcher p_i46202_1_, RegionRenderCacheBuilder p_i46202_2_) { + super(p_i46202_1_, p_i46202_2_); + } + + @Override + protected void processTask(ChunkCompileTaskGenerator p_178474_1_) throws InterruptedException { + super.processTask(p_178474_1_); + } + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/replay/ReplayHandler.java b/src/main/java/eu/crushedpixel/replaymod/replay/ReplayHandler.java index 86982483..61c7dd6b 100755 --- a/src/main/java/eu/crushedpixel/replaymod/replay/ReplayHandler.java +++ b/src/main/java/eu/crushedpixel/replaymod/replay/ReplayHandler.java @@ -5,6 +5,7 @@ import eu.crushedpixel.replaymod.ReplayMod; import eu.crushedpixel.replaymod.entities.CameraEntity; import eu.crushedpixel.replaymod.holders.*; import eu.crushedpixel.replaymod.registry.PlayerHandler; +import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.utils.ReplayFile; import eu.crushedpixel.replaymod.utils.ReplayFileIO; import net.minecraft.client.Minecraft; @@ -86,8 +87,8 @@ public class ReplayHandler { return currentEntity == cameraEntity; } - public static void startPath(boolean save) { - if(!ReplayHandler.isInPath()) ReplayProcess.startReplayProcess(save); + public static void startPath(RenderOptions renderOptions) { + if(!ReplayHandler.isInPath()) ReplayProcess.startReplayProcess(renderOptions); } public static void interruptReplay() { @@ -322,7 +323,11 @@ public class ReplayHandler { return inReplay; } - public static void startReplay(File file) throws NoSuchMethodException, SecurityException, NoSuchFieldException { + public static void startReplay(File file) { + startReplay(file, true); + } + + public static void startReplay(File file, boolean asyncMode) { ReplayMod.chatMessageHandler.initialize(); mc.ingameGUI.getChatGUI().clearChatMessages(); @@ -358,7 +363,7 @@ public class ReplayHandler { PlayerVisibility visibility = currentReplayFile.visibility().get(); PlayerHandler.loadPlayerVisibilityConfiguration(visibility); - ReplayMod.replaySender = new ReplaySender(currentReplayFile, true); + ReplayMod.replaySender = new ReplaySender(currentReplayFile, asyncMode); channel.pipeline().addFirst(ReplayMod.replaySender); channel.pipeline().fireChannelActive(); diff --git a/src/main/java/eu/crushedpixel/replaymod/replay/ReplayProcess.java b/src/main/java/eu/crushedpixel/replaymod/replay/ReplayProcess.java index 40af0ca4..b82182a4 100755 --- a/src/main/java/eu/crushedpixel/replaymod/replay/ReplayProcess.java +++ b/src/main/java/eu/crushedpixel/replaymod/replay/ReplayProcess.java @@ -2,7 +2,6 @@ package eu.crushedpixel.replaymod.replay; import eu.crushedpixel.replaymod.ReplayMod; import eu.crushedpixel.replaymod.chat.ChatMessageHandler.ChatMessageType; -import eu.crushedpixel.replaymod.gui.GuiCancelRender; import eu.crushedpixel.replaymod.holders.Keyframe; import eu.crushedpixel.replaymod.holders.Position; import eu.crushedpixel.replaymod.holders.PositionKeyframe; @@ -10,14 +9,13 @@ import eu.crushedpixel.replaymod.holders.TimeKeyframe; import eu.crushedpixel.replaymod.interpolation.LinearPoint; import eu.crushedpixel.replaymod.interpolation.LinearTimestamp; import eu.crushedpixel.replaymod.interpolation.SplinePoint; +import eu.crushedpixel.replaymod.settings.RenderOptions; import eu.crushedpixel.replaymod.timer.EnchantmentTimer; import eu.crushedpixel.replaymod.timer.MCTimerHandler; -import eu.crushedpixel.replaymod.video.ScreenCapture; -import eu.crushedpixel.replaymod.video.VideoWriter; +import eu.crushedpixel.replaymod.video.VideoRenderer; import net.minecraft.client.Minecraft; -import net.minecraft.client.renderer.chunk.RenderChunk; -import java.awt.image.BufferedImage; +import java.io.IOException; public class ReplayProcess { @@ -79,11 +77,11 @@ public class ReplayProcess { EnchantmentTimer.resetRecordingTime(); } - public static void startReplayProcess(boolean record) { + public static void startReplayProcess(RenderOptions renderOptions) { ReplayHandler.selectKeyframe(null); resetProcess(); - isVideoRecording = record; + isVideoRecording = renderOptions != null; ReplayMod.chatMessageHandler.initialize(); @@ -97,24 +95,28 @@ public class ReplayProcess { ReplayHandler.setInPath(true); ReplayMod.replaySender.setAsyncMode(false); - //gets first Timestamp and sets Replay Time to it - TimeKeyframe tf = ReplayHandler.getFirstTimeKeyframe(); - if(tf != null) { - int ts = tf.getTimestamp(); - if(ts < ReplayMod.replaySender.currentTimeStamp()) { - mc.displayGuiScreen(null); + if (renderOptions == null) { + //gets first Timestamp and sets Replay Time to it + TimeKeyframe tf = ReplayHandler.getFirstTimeKeyframe(); + if (tf != null) { + int ts = tf.getTimestamp(); + if (ts < ReplayMod.replaySender.currentTimeStamp()) { + mc.displayGuiScreen(null); + } + ReplayMod.replaySender.sendPacketsTill(ts); } - ReplayMod.replaySender.sendPacketsTill(ts); - } - ReplayMod.chatMessageHandler.addLocalizedChatMessage("replaymod.chat.pathstarted", ChatMessageType.INFORMATION); - - //if video is recording, the Replay Process takes control over the Minecraft Timer - if(isVideoRecording()) { + ReplayMod.chatMessageHandler.addLocalizedChatMessage("replaymod.chat.pathstarted", ChatMessageType.INFORMATION); MCTimerHandler.setTimerSpeed(1f); - MCTimerHandler.setPassiveTimer(); } else { - MCTimerHandler.setTimerSpeed(1f); + try { + isVideoRecording = true; + boolean success = new VideoRenderer(renderOptions).renderVideo(); + isVideoRecording = false; + stopReplayProcess(success); + } catch (IOException e) { + e.printStackTrace(); + } } } @@ -125,9 +127,6 @@ public class ReplayProcess { if(finished) ReplayMod.chatMessageHandler.addLocalizedChatMessage("replaymod.chat.pathfinished", ChatMessageType.INFORMATION); else { ReplayMod.chatMessageHandler.addLocalizedChatMessage("replaymod.chat.pathinterrupted", ChatMessageType.INFORMATION); - if(isVideoRecording()) { - VideoWriter.abortRecording(); - } } ReplayHandler.setInPath(false); @@ -143,14 +142,17 @@ public class ReplayProcess { public static void unblockAndTick(boolean justCheck) { if(!deepBlock) blocked = false; if(!blocked || !isVideoRecording()) - pathTick(isVideoRecording(), justCheck); + ReplayProcess.tickReplay(justCheck); } //if justCheck is true, no Screenshot will be taken, it will only be checked //whether all chunks have been rendered. This is necessary because no Render ticks //are called if the Timer speed is set to 0, leading to this method never being //called from the RenderWorldLastEvent handlers. - private static void pathTick(boolean recording, boolean justCheck) { + public static void tickReplay(boolean justCheck) { + if (isVideoRecording) { + return; + } if(ReplayMod.replaySender.isHurrying()) { lastRealTime = System.currentTimeMillis(); return; @@ -166,35 +168,8 @@ public class ReplayProcess { MCTimerHandler.setTicks(100); } - if(recording && ((ReplayMod.replaySettings.getWaitForChunks() && RenderChunk.renderChunksUpdated != 0) || mc.currentScreen instanceof GuiCancelRender)) { - if(!firstTime) { - MCTimerHandler.setTimerSpeed(0f); - MCTimerHandler.setPartialTicks(0f); - MCTimerHandler.setRenderPartialTicks(0f); - MCTimerHandler.setTicks(0); - resetTimer = true; - } - return; - } else if(recording && ReplayMod.replaySettings.getWaitForChunks()) { - MCTimerHandler.setTimerSpeed((float) lastSpeed); - //MCTimerHandler.setRenderPartialTicks(lastRenderPartialTicks); - if(resetTimer) { - MCTimerHandler.setPartialTicks(lastPartialTicks); - MCTimerHandler.setRenderPartialTicks(lastRenderPartialTicks); - MCTimerHandler.setTicks(lastTicks); - resetTimer = false; - } - } - if(justCheck) return; - if(recording) { - if(blocked) return; - - deepBlock = true; - blocked = true; - } - int posCount = ReplayHandler.getPosKeyframeCount(); int timeCount = ReplayHandler.getTimeKeyframeCount(); @@ -233,16 +208,11 @@ public class ReplayProcess { if(!calculated) { calculated = true; if(posCount > 1 && motionSpline != null) - motionSpline.calcSpline(); + motionSpline.prepare(); } long curTime = System.currentTimeMillis(); - long timeStep; - if(recording) { - timeStep = 1000 / ReplayMod.replaySettings.getVideoFramerate(); - } else { - timeStep = curTime - lastRealTime; - } + long timeStep = curTime - lastRealTime; int curRealReplayTime = (int) (lastRealReplayTime + timeStep); @@ -333,14 +303,13 @@ public class ReplayProcess { } } - ReplayHandler.setCameraTilt(pos.getRoll()); - Integer curTimestamp = null; if(timeLinear != null && timeCount > 1) { curTimestamp = timeLinear.getPoint(Math.max(0, Math.min(1, timePos))); } if(pos != null) { + ReplayHandler.setCameraTilt(pos.getRoll()); ReplayHandler.getCameraEntity().movePath(pos); } @@ -348,11 +317,6 @@ public class ReplayProcess { //if(curSpeed > 0) lastSpeed = curSpeed; - if(recording) { - MCTimerHandler.updateTimer((1f / ReplayMod.replaySettings.getVideoFramerate())); - EnchantmentTimer.increaseRecordingTime((1000 / ReplayMod.replaySettings.getVideoFramerate())); - } - lastPartialTicks = MCTimerHandler.getPartialTicks(); lastRenderPartialTicks = MCTimerHandler.getRenderTicks(); lastTicks = MCTimerHandler.getTicks(); @@ -365,33 +329,13 @@ public class ReplayProcess { lastRealReplayTime = curRealReplayTime; lastRealTime = curTime; - if(isVideoRecording()) { - try { - if(!VideoWriter.isRecording() && ReplayHandler.isInPath()) { - VideoWriter.startRecording(mc.displayWidth, mc.displayHeight); - } else { - final BufferedImage screen = ScreenCapture.captureScreen(); - VideoWriter.writeImage(screen); - } - } catch(Exception e) { - e.printStackTrace(); - } - } - if(requestFinish) { stopReplayProcess(true); requestFinish = false; - if(recording) { - VideoWriter.endRecording(); - } } if((splinePos >= 1 || posCount <= 1) && (timePos >= 1 || timeCount <= 1)) { requestFinish = true; } - - if(recording) { - deepBlock = false; - } } } diff --git a/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java b/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java new file mode 100644 index 00000000..682e530b --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/settings/RenderOptions.java @@ -0,0 +1,68 @@ +package eu.crushedpixel.replaymod.settings; + +import eu.crushedpixel.replaymod.video.frame.FrameRenderer; + +import static org.apache.commons.lang3.Validate.*; + +public final class RenderOptions { + private FrameRenderer renderer; + private float quality = 0.5f; + private int fps = 30; + private boolean waitForChunks = true; + private boolean isLinearMovement = false; + + public RenderOptions(FrameRenderer renderer) { + this.renderer = notNull(renderer); + } + + public FrameRenderer getRenderer() { + return renderer; + } + + public void setRenderer(FrameRenderer renderer) { + this.renderer = notNull(renderer); + } + + public float getQuality() { + return quality; + } + + public void setQuality(float quality) { + inclusiveBetween(0f, 1f, quality); + this.quality = quality; + } + + public int getFps() { + return fps; + } + + public void setFps(int fps) { + isTrue(fps > 0, "Fps must be positive."); + this.fps = fps; + } + + public boolean isWaitForChunks() { + return waitForChunks; + } + + public void setWaitForChunks(boolean waitForChunks) { + this.waitForChunks = waitForChunks; + } + + public boolean isLinearMovement() { + return isLinearMovement; + } + + public void setLinearMovement(boolean isLinearMovement) { + this.isLinearMovement = isLinearMovement; + } + + public RenderOptions copy() { + RenderOptions copy = new RenderOptions(renderer); + copy.quality = this.quality; + copy.fps = this.fps; + copy.waitForChunks = this.waitForChunks; + copy.isLinearMovement = this.isLinearMovement; + return copy; + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/timer/MCTimerHandler.java b/src/main/java/eu/crushedpixel/replaymod/timer/MCTimerHandler.java index 6bc6f8ff..37711ed2 100755 --- a/src/main/java/eu/crushedpixel/replaymod/timer/MCTimerHandler.java +++ b/src/main/java/eu/crushedpixel/replaymod/timer/MCTimerHandler.java @@ -44,6 +44,10 @@ public class MCTimerHandler { return mc.timer.renderPartialTicks; } + public static Timer getTimer() { + return mc.timer; + } + public static void advanceTicks(int ticks) { mc.timer.elapsedTicks += ticks; } diff --git a/src/main/java/eu/crushedpixel/replaymod/utils/JailingQueue.java b/src/main/java/eu/crushedpixel/replaymod/utils/JailingQueue.java new file mode 100644 index 00000000..e5339b2d --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/utils/JailingQueue.java @@ -0,0 +1,120 @@ +package eu.crushedpixel.replaymod.utils; + +import com.google.common.base.Preconditions; + +import java.util.*; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +public class JailingQueue extends AbstractQueue implements BlockingQueue { + private final BlockingQueue delegate; + private final Set jailed = new HashSet(); + + public JailingQueue(BlockingQueue delegate) { + this.delegate = delegate; + } + + public synchronized void jail(int atLeast) { + while (jailed.size() < atLeast) { + try { + wait(); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + } + + public synchronized void free(Thread thread) { + Preconditions.checkState(jailed.remove(thread), "Thread is not jailed."); + thread.interrupt(); + } + + public synchronized void freeAll() { + jailed.clear(); + notifyAll(); + } + + private synchronized void tryAccess() { + jailed.add(Thread.currentThread()); + notifyAll(); + while (jailed.contains(Thread.currentThread())) { + try { + wait(); + } catch (InterruptedException e) { + Thread.interrupted(); + } + } + } + + @Override + public Iterator iterator() { + tryAccess(); + return delegate.iterator(); + } + + @Override + public int size() { + tryAccess(); + return delegate.size(); + } + + @Override + public void put(T t) throws InterruptedException { + tryAccess(); + delegate.put(t); + } + + @Override + public boolean offer(T t, long timeout, TimeUnit unit) throws InterruptedException { + tryAccess(); + return delegate.offer(t, timeout, unit); + } + + @Override + public T take() throws InterruptedException { + tryAccess(); + return delegate.take(); + } + + @Override + public T poll(long timeout, TimeUnit unit) throws InterruptedException { + tryAccess(); + return delegate.poll(timeout, unit); + } + + @Override + public int remainingCapacity() { + tryAccess(); + return delegate.remainingCapacity(); + } + + @Override + public int drainTo(Collection c) { + tryAccess(); + return delegate.drainTo(c); + } + + @Override + public int drainTo(Collection c, int maxElements) { + tryAccess(); + return delegate.drainTo(c, maxElements); + } + + @Override + public boolean offer(T t) { + tryAccess(); + return delegate.offer(t); + } + + @Override + public T poll() { + tryAccess(); + return delegate.poll(); + } + + @Override + public T peek() { + tryAccess(); + return delegate.peek(); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/ReplayTimer.java b/src/main/java/eu/crushedpixel/replaymod/video/ReplayTimer.java index 99dcfb4e..55d2ffa5 100755 --- a/src/main/java/eu/crushedpixel/replaymod/video/ReplayTimer.java +++ b/src/main/java/eu/crushedpixel/replaymod/video/ReplayTimer.java @@ -1,6 +1,5 @@ package eu.crushedpixel.replaymod.video; -import eu.crushedpixel.replaymod.replay.ReplayProcess; import net.minecraft.util.Timer; public class ReplayTimer extends Timer { @@ -11,7 +10,5 @@ public class ReplayTimer extends Timer { @Override public void updateTimer() { - if(ReplayProcess.isVideoRecording()) return; - super.updateTimer(); } } diff --git a/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java new file mode 100644 index 00000000..dcef8492 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/VideoRenderer.java @@ -0,0 +1,391 @@ +package eu.crushedpixel.replaymod.video; + +import eu.crushedpixel.replaymod.ReplayMod; +import eu.crushedpixel.replaymod.entities.CameraEntity; +import eu.crushedpixel.replaymod.gui.GuiVideoRenderer; +import eu.crushedpixel.replaymod.holders.Keyframe; +import eu.crushedpixel.replaymod.holders.Position; +import eu.crushedpixel.replaymod.holders.PositionKeyframe; +import eu.crushedpixel.replaymod.holders.TimeKeyframe; +import eu.crushedpixel.replaymod.interpolation.Interpolation; +import eu.crushedpixel.replaymod.interpolation.LinearPoint; +import eu.crushedpixel.replaymod.interpolation.LinearTimestamp; +import eu.crushedpixel.replaymod.interpolation.SplinePoint; +import eu.crushedpixel.replaymod.renderer.ChunkLoadingRenderGlobal; +import eu.crushedpixel.replaymod.replay.ReplayHandler; +import eu.crushedpixel.replaymod.replay.ReplaySender; +import eu.crushedpixel.replaymod.settings.RenderOptions; +import eu.crushedpixel.replaymod.timer.EnchantmentTimer; +import eu.crushedpixel.replaymod.timer.MCTimerHandler; +import eu.crushedpixel.replaymod.video.frame.FrameRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ScaledResolution; +import net.minecraft.util.Timer; +import org.lwjgl.input.Mouse; +import org.lwjgl.opengl.Display; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.concurrent.FutureTask; + +import static net.minecraft.client.renderer.GlStateManager.*; +import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; +import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT; + +public class VideoRenderer { + private final Minecraft mc = Minecraft.getMinecraft(); + private final FrameRenderer frameRenderer; + private final VideoWriter videoWriter; + private final ReplaySender replaySender; + private final RenderOptions options; + + private int fps; + private boolean mouseWasGrabbed; + + private Interpolation movement; + private Interpolation time; + + private int framesDone; + private int totalFrames; + + private final GuiVideoRenderer gui; + private boolean frameRendererUpdatesGui; + private boolean paused; + private boolean cancelled; + + public VideoRenderer(RenderOptions options) throws IOException { + this.frameRenderer = options.getRenderer(); + this.videoWriter = new VideoWriter(frameRenderer.getVideoWidth(), frameRenderer.getVideoHeight(), + options.getFps(), options.getQuality(), 10); + this.gui = new GuiVideoRenderer(this); + this.replaySender = ReplayMod.replaySender; + this.options = options; + } + + /** + * Render this video. + * @return {@code true} if rendering was successful, {@code false} if the user aborted rendering (or the window was closed) + * @throws IOException {@link Minecraft#runTick()} can apparently throw these, don't question it! + */ + public boolean renderVideo() throws IOException { + setup(); + + Timer timer = MCTimerHandler.getTimer(); + + replaySender.sendPacketsTill(time.getPoint(0)); + // Pre-tick twice, once to process all the packets + tick(); + // the second time to prevent interpolation of the player position + tick(); + + + framesDone = 0; + while (framesDone < totalFrames && !cancelled) { + pauseIfNeeded(); + + if (Display.isActive() && Display.isCloseRequested()) { + mc.shutdown(); + return false; + } + + updateTime(timer, framesDone); + + int elapsedTicks = timer.elapsedTicks; + while (elapsedTicks-- > 0) { + tick(); + } + + frame(timer); + framesDone++; + + if (!frameRendererUpdatesGui) { + drawGui(); + } + System.gc(); + } + + finish(); + return !cancelled; + } + + private void setup() { + if (Mouse.isGrabbed()) { + mouseWasGrabbed = true; + } + Mouse.setGrabbed(false); + MCTimerHandler.setTimerSpeed(1f); + MCTimerHandler.setPassiveTimer(); + + fps = options.getFps(); + if (options.isLinearMovement()) { + movement = new LinearPoint(); + } else { + movement = new SplinePoint(); + } + time = new LinearTimestamp(); + + int duration = 0; + int posKeyframes = 0; + for (Keyframe keyframe : ReplayHandler.getKeyframes()) { + if (keyframe.getRealTimestamp() > duration) { + duration = keyframe.getRealTimestamp(); + } + if(keyframe instanceof PositionKeyframe) { + movement.addPoint(((PositionKeyframe) keyframe).getPosition()); + posKeyframes++; + } + if (keyframe instanceof TimeKeyframe) { + time.addPoint(((TimeKeyframe) keyframe).getTimestamp()); + } + } + totalFrames = duration*fps/1000; + + if (posKeyframes >= 2) { + movement.prepare(); + } else { + movement = null; // No movement occurring + } + time.prepare(); + + frameRendererUpdatesGui = frameRenderer.setRenderPreviewCallback(new Runnable() { + @Override + public void run() { + drawGui(); + } + }); + frameRenderer.setup(); + + ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + gui.setWorldAndResolution(mc, scaled.getScaledWidth(), scaled.getScaledHeight()); + + if (options.isWaitForChunks()) { + ChunkLoadingRenderGlobal.install(mc); + } + } + + private void finish() { + if (cancelled) { + videoWriter.abortRecording(); + } else { + videoWriter.endRecording(); + } + try { + videoWriter.waitForFinish(); + } catch (InterruptedException ignored) { } + + frameRenderer.tearDown(); + if (mouseWasGrabbed) { + Mouse.setGrabbed(true); + } + mc.displayGuiScreen(null); + if (mc.renderGlobal instanceof ChunkLoadingRenderGlobal) { + ((ChunkLoadingRenderGlobal) mc.renderGlobal).uninstall(); + } + } + + private void updateCam() { + if (ReplayHandler.getCameraEntity() == null) { + if (mc.theWorld == null) { + return; // World hasn't been sent yet + } + ReplayHandler.setCameraEntity(new CameraEntity(mc.theWorld)); + } + int videoTime = framesDone * 1000 / fps; + int posCount = ReplayHandler.getPosKeyframeCount(); + + Position pos; + PositionKeyframe lastPos = ReplayHandler.getPreviousPositionKeyframe(videoTime); + if (movement == null || lastPos == null) { + // Stay at one position, no movement + pos = ReplayHandler.getNextPositionKeyframe(-1).getPosition(); + } else { + // Position interpolation + PositionKeyframe nextPos = ReplayHandler.getNextPositionKeyframe(videoTime); + + int lastPosStamp = lastPos.getRealTimestamp(); + int nextPosStamp = (nextPos == null ? lastPos : nextPos).getRealTimestamp(); + + int diffLength = nextPosStamp - lastPosStamp; + float diffPct = (float) (videoTime - lastPosStamp) / diffLength; + if(Float.isInfinite(diffPct) || Float.isNaN(diffPct)) diffPct = 0; + + float totalPct = (ReplayHandler.getKeyframeIndex(lastPos) + diffPct) / (posCount-1); + pos = movement.getPoint(Math.max(0, Math.min(1, totalPct))); + } + ReplayHandler.setCameraTilt(pos.getRoll()); + ReplayHandler.getCameraEntity().movePath(pos); + + // Make sure we're spectating the camera entity + // We do not use .spectateCamera() as that method sets the position of the camera to the previous + // entity, overriding our calculations + ReplayHandler.spectateEntity(ReplayHandler.getCameraEntity()); + } + + private void updateTime(Timer timer, int framesDone) { + int videoTime = framesDone * 1000 / fps; + int timeCount = ReplayHandler.getTimeKeyframeCount(); + + // WARNING: The rest of this method contains some magic for which Marius is responsible + // Time interpolation + TimeKeyframe lastTime = ReplayHandler.getPreviousTimeKeyframe(videoTime); + TimeKeyframe nextTime = ReplayHandler.getNextTimeKeyframe(videoTime); + + int lastTimeStamp = 0; + int nextTimeStamp = 0; + + double curSpeed = 0; + + if(nextTime != null || lastTime != null) { + if(nextTime != null && lastTime != null && nextTime.getRealTimestamp() == lastTime.getRealTimestamp()) { + curSpeed = 0; + } else { + if(nextTime != null) { + nextTimeStamp = nextTime.getRealTimestamp(); + } else { + nextTimeStamp = lastTime.getRealTimestamp(); + } + + if(lastTime != null) { + lastTimeStamp = lastTime.getRealTimestamp(); + } else { + lastTimeStamp = nextTime.getRealTimestamp(); + } + + if(!(nextTime == null || lastTime == null)) { + if(lastTimeStamp == nextTimeStamp) curSpeed = 0f; + else curSpeed = ((double)((nextTime.getTimestamp()-lastTime.getTimestamp())))/((double)((nextTimeStamp-lastTimeStamp))); + } + + if(lastTimeStamp == nextTimeStamp) { + curSpeed = 0f; + } + } + } + + int currentTimeDiff = nextTimeStamp - lastTimeStamp; + int currentTime = videoTime - lastTimeStamp; + + float currentTimeStepPerc = (float)currentTime/(float)currentTimeDiff; //The percentage of the travelled path between the current timestamps + if(Float.isInfinite(currentTimeStepPerc) || Float.isNaN(currentTimeStepPerc)) currentTimeStepPerc = 0; + + float timePos = (ReplayHandler.getKeyframeIndex(lastTime) + currentTimeStepPerc) / (timeCount-1f); + + Integer replayTime = time.getPoint(Math.max(0, Math.min(1, timePos))); + if(replayTime != null) { + replaySender.sendPacketsTill(replayTime); + } + + if(curSpeed > 0) { + replaySender.setReplaySpeed(curSpeed); + } + + // Update Timer + EnchantmentTimer.increaseRecordingTime(1000 / fps); + + timer.elapsedPartialTicks += timer.timerSpeed * 20 / fps; + timer.elapsedTicks = (int) timer.elapsedPartialTicks; + timer.elapsedPartialTicks -= timer.elapsedTicks; + timer.renderPartialTicks = timer.elapsedPartialTicks; + } + + private void tick() throws IOException { + synchronized (mc.scheduledTasks) { + while (!mc.scheduledTasks.isEmpty()) { + ((FutureTask) mc.scheduledTasks.poll()).run(); + } + } + + mc.currentScreen = gui; + mc.runTick(); + } + + private void frame(Timer timer) { + updateCam(); + + BufferedImage frame = null; + while (frame == null) { + try { + frame = frameRenderer.captureFrame(timer); + } catch (OutOfMemoryError e) { + System.out.println("Caught oom error-> calling garbage collector, decreasing queue size and waiting for video writer"); + System.gc(); + videoWriter.waitTillQueueEmpty(); + } + } + while (!videoWriter.writeImage(frame, false)) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + return; + } + drawGui(); + } + } + + private void pauseIfNeeded() { + while (paused && !cancelled && !Display.isCloseRequested()) { + drawGui(); + try { + Thread.sleep(20); + } catch (InterruptedException e) { + return; + } + } + } + + public void drawGui() { + pushMatrix(); + clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + enableTexture2D(); + mc.getFramebuffer().bindFramebuffer(true); + mc.entityRenderer.setupOverlayRendering(); + + try { + gui.handleInput(); + } catch (IOException e) { + // That's a strange exception from this kind of method O_o + // It isn't actually thrown here, so we'll deal with it the easy way + throw new RuntimeException(e); + } + + ScaledResolution scaled = new ScaledResolution(mc, mc.displayWidth, mc.displayHeight); + int mouseX = Mouse.getX() * scaled.getScaledWidth() / mc.displayWidth; + int mouseY = scaled.getScaledHeight() - Mouse.getY() * scaled.getScaledHeight() / mc.displayHeight - 1; + gui.drawScreen(mouseX, mouseY, 0); + + mc.getFramebuffer().unbindFramebuffer(); + popMatrix(); + pushMatrix(); + mc.getFramebuffer().framebufferRender(mc.displayWidth, mc.displayHeight); + popMatrix(); + + Display.update(); + if (Mouse.isGrabbed()) { + Mouse.setGrabbed(false); + } + } + + public int getFramesDone() { + return framesDone; + } + + public int getTotalFrames() { + return totalFrames; + } + + public FrameRenderer getFrameRenderer() { + return frameRenderer; + } + + public void setPaused(boolean paused) { + this.paused = paused; + } + + public boolean isPaused() { + return paused; + } + + public void cancel() { + this.cancelled = true; + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java b/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java index 6f75a0b8..bc6d599c 100755 --- a/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java +++ b/src/main/java/eu/crushedpixel/replaymod/video/VideoWriter.java @@ -1,6 +1,6 @@ package eu.crushedpixel.replaymod.video; -import eu.crushedpixel.replaymod.ReplayMod; +import com.google.common.base.Preconditions; import eu.crushedpixel.replaymod.utils.ReplayFileIO; import org.monte.media.*; import org.monte.media.FormatKeys.MediaType; @@ -9,132 +9,170 @@ import org.monte.media.math.Rational; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.text.SimpleDateFormat; import java.util.Calendar; +import java.util.LinkedList; import java.util.Queue; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; public class VideoWriter { private static final String DATE_FORMAT = "yyyy_MM_dd_HH_mm_ss"; - private static final SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT); + private static final SimpleDateFormat FILE_FORMAT = new SimpleDateFormat(DATE_FORMAT); private static final String VIDEO_EXTENSION = ".avi"; - private static MovieWriter out; - private static File file; - private static boolean isRecording = false; - private static boolean requestFinish = false; - private static boolean abort = false; + private final File file; + private final MovieWriter out; + private final Buffer buf; + private final int track; - private static Buffer buf; - private static int track; - private static Queue toWrite = new LinkedBlockingQueue(); + private volatile boolean active = true; + private volatile boolean cancelled = false; - public static boolean isRecording() { - return isRecording; + private int queueLimit; + private final Queue toWrite; + private final Thread writerThread; + + private final Lock lock = new ReentrantLock(); + private final Condition emptyCondition = lock.newCondition(); + private final Condition noLongerEmptyCondition = lock.newCondition(); + private final Condition noLongerFullCondition = lock.newCondition(); + + public VideoWriter(int width, int height, int fps, float quality) throws IOException { + this(width, height, fps, quality, Integer.MAX_VALUE); } - public static void startRecording(int width, int height) { - if(isRecording) { - IllegalStateException up = new IllegalStateException("VideoWriter is already recording!"); - throw up; //lolololo - } - isRecording = true; + public VideoWriter(int width, int height, int fps, float quality, int queueLimit) throws IOException { + this.queueLimit = queueLimit; + this.toWrite = new LinkedList(); - toWrite = new LinkedBlockingQueue(); + File folder = ReplayFileIO.getRenderFolder(); + String fileName = FILE_FORMAT.format(Calendar.getInstance().getTime()); + file = new File(folder, fileName + VIDEO_EXTENSION); + Files.createFile(file.toPath()); - try { - File folder = ReplayFileIO.getRenderFolder(); + out = Registry.getInstance().getWriter(file); + Format format = new Format(FormatKeys.MediaTypeKey, MediaType.VIDEO, + FormatKeys.EncodingKey, VideoFormatKeys.ENCODING_AVI_MJPG, + FormatKeys.FrameRateKey, new Rational(fps, 1), + VideoFormatKeys.WidthKey, width, + VideoFormatKeys.HeightKey, height, + VideoFormatKeys.DepthKey, 24, + VideoFormatKeys.QualityKey, quality); - String fileName = sdf.format(Calendar.getInstance().getTime()); + track = out.addTrack(format); - file = new File(folder, fileName + VIDEO_EXTENSION); - file.createNewFile(); + buf = new Buffer(); + buf.format = new Format(VideoFormatKeys.DataClassKey, BufferedImage.class); + buf.sampleDuration = out.getFormat(track).get(VideoFormatKeys.FrameRateKey).inverse(); - out = Registry.getInstance().getWriter(file); - Format format = new Format(FormatKeys.MediaTypeKey, MediaType.VIDEO, - FormatKeys.EncodingKey, VideoFormatKeys.ENCODING_AVI_MJPG, - FormatKeys.FrameRateKey, new Rational(ReplayMod.replaySettings.getVideoFramerate(), 1), - VideoFormatKeys.WidthKey, width, - VideoFormatKeys.HeightKey, height, - VideoFormatKeys.DepthKey, 24, - VideoFormatKeys.QualityKey, (float) ReplayMod.replaySettings.getVideoQuality()); - - - track = out.addTrack(format); - - buf = new Buffer(); - buf.format = new Format(VideoFormatKeys.DataClassKey, BufferedImage.class); - buf.sampleDuration = out.getFormat(track).get(VideoFormatKeys.FrameRateKey).inverse(); - } catch(IOException e) { - e.printStackTrace(); - } - - Thread t = new Thread(new Runnable() { + writerThread = new Thread(new Runnable() { @Override public void run() { - while(true) { - if(toWrite.isEmpty() || abort) { - if(requestFinish) { - requestFinish = false; - isRecording = false; - try { - out.close(); - if(abort) { - file.delete(); - } - } catch(IOException e) { - e.printStackTrace(); - } - abort = false; - toWrite = new LinkedBlockingQueue(); - return; - } + while(!cancelled && (active || !toWrite.isEmpty())) { + try { + lock.lockInterruptibly(); + BufferedImage img; try { - Thread.sleep(10); - } catch(Exception e) { + img = toWrite.poll(); + if (img == null) { + noLongerEmptyCondition.await(); + img = toWrite.poll(); + } + noLongerFullCondition.signal(); + if (toWrite.isEmpty()) { + emptyCondition.signalAll(); + } + } finally { + lock.unlock(); + } + buf.data = img; + try { + out.write(track, buf); + } catch (IOException e) { e.printStackTrace(); } - } else { - write(); + } catch (InterruptedException ignored) { } } + try { + toWrite.clear(); + out.close(); + if (cancelled) { + Files.delete(file.toPath()); + } + } catch (IOException e) { + e.printStackTrace(); + } } }); - t.start(); + writerThread.start(); } - public static void writeImage(BufferedImage image) { - if(requestFinish || !isRecording) { - IllegalStateException up = new IllegalStateException( - "The VideoWriter is currently not available. Please try again later."); - throw up; //lolololo^2 - } - - toWrite.add(image); + public void setQueueLimit(int limit) { + this.queueLimit = limit; } - public static void endRecording() { - if(!isRecording) return; - requestFinish = true; - } + /** + * Add the image to the writer queue. + * @param image The image + * @param waitIfFull Whether to wait if the queue is full or to return immediately + * @return {@code} true if the image was added, {@code false} if it could not be added due to the queue being full + * and {@code waitIfFull} being {@code false} + */ + public boolean writeImage(BufferedImage image, boolean waitIfFull) { + Preconditions.checkState(active, "This VideoWriter has already been closed."); - private static void write() { + lock.lock(); try { - BufferedImage img = toWrite.poll(); - if(img != null) { - buf.data = img; - out.write(track, buf); + while (toWrite.size() >= queueLimit) { + if (waitIfFull) { + noLongerFullCondition.await(); + } else { + return false; + } } - } catch(IOException e) { + toWrite.offer(image); + noLongerEmptyCondition.signal(); + return true; + } catch (InterruptedException ignored) { + } finally { + lock.unlock(); + } + return false; + } + + public void endRecording() { + active = false; + writerThread.interrupt(); + } + + public void abortRecording() { + cancelled = true; + active = false; + writerThread.interrupt(); + } + + public void waitTillQueueEmpty() { + lock.lock(); + try { + if (!toWrite.isEmpty()) { + emptyCondition.await(); + } + } catch (InterruptedException e) { e.printStackTrace(); + } finally { + lock.unlock(); } } - public static void abortRecording() { - requestFinish = true; - abort = true; + public void waitForFinish() throws InterruptedException { + Preconditions.checkState(!active, "Video writer still active."); + writerThread.join(); } } diff --git a/src/main/java/eu/crushedpixel/replaymod/video/entity/CubicEntityRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/entity/CubicEntityRenderer.java new file mode 100644 index 00000000..f9fe24df --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/entity/CubicEntityRenderer.java @@ -0,0 +1,109 @@ +package eu.crushedpixel.replaymod.video.entity; + +import eu.crushedpixel.replaymod.replay.ReplayHandler; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.entity.Entity; + +public class CubicEntityRenderer extends CustomEntityRenderer { + + public static enum Direction { + TOP(1), BOTTOM(9), LEFT(4), FRONT(5), RIGHT(6), BACK(7); + + private final int frame; + + Direction(int frame) { + this.frame = frame; + } + + public static Direction forFrame(int frame) { + for (Direction d : values()) { + if (d.frame == frame) { + return d; + } + } + return null; + } + + public int getCubicFrame() { + return frame; + } + } + + private final boolean stable; + private Direction direction; + + public CubicEntityRenderer(boolean stable) { + this.stable = stable; + } + + public void setFrame(int id) { + setDirection(Direction.forFrame(id)); + } + + public void setDirection(Direction direction) { + this.direction = direction; + } + + @Override + protected void gluPerspective(float fovY, float aspect, float zNear, float zFar) { + super.gluPerspective(90, 1, zNear, zFar); + } + + @Override + protected void setupCameraTransform(float partialTicks) { + Entity entity = mc.getRenderViewEntity(); + float orgYaw = entity.rotationYaw; + float orgPitch = entity.rotationPitch; + float orgPrevYaw = entity.prevRotationYaw; + float orgPrevPitch = entity.prevRotationPitch; + float orgRoll = ReplayHandler.getCameraTilt(); + + super.setupCameraTransform(partialTicks); + + entity.rotationYaw = orgYaw; + entity.rotationPitch = orgPitch; + entity.prevRotationYaw = orgPrevYaw; + entity.prevRotationPitch = orgPrevPitch; + if (stable) { + ReplayHandler.setCameraTilt(orgRoll); + } + } + + @Override + protected void orientCamera(float partialTicks) { + Entity entity = mc.getRenderViewEntity(); + + // Rotate + switch(direction) { + case FRONT: + GlStateManager.rotate(0, 0.0F, 1.0F, 0.0F); + break; + case RIGHT: + GlStateManager.rotate(90, 0.0F, 1.0F, 0.0F); + break; + case BACK: + GlStateManager.rotate(180, 0.0F, 1.0F, 0.0F); + break; + case LEFT: + GlStateManager.rotate(-90, 0.0F, 1.0F, 0.0F); + break; + case BOTTOM: + GlStateManager.rotate(90, 1.0F, 0.0F, 0.0F); + break; + case TOP: + GlStateManager.rotate(-90, 1.0F, 0.0F, 0.0F); + break; + } + + if (stable) { + // Stop the minecraft code from doing any rotation + entity.prevRotationPitch = entity.rotationPitch = 0; + entity.prevRotationYaw = entity.rotationYaw = 0; + ReplayHandler.setCameraTilt(0); + } + + // Minecraft goes back a little so we have to invert that + GlStateManager.translate(0.0F, 0.0F, 0.1F); + super.orientCamera(partialTicks); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/entity/CustomEntityRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/entity/CustomEntityRenderer.java new file mode 100644 index 00000000..bebf1dae --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/entity/CustomEntityRenderer.java @@ -0,0 +1,225 @@ +package eu.crushedpixel.replaymod.video.entity; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.particle.EffectRenderer; +import net.minecraft.client.renderer.*; +import net.minecraft.client.renderer.culling.ClippingHelperImpl; +import net.minecraft.client.renderer.culling.ICamera; +import net.minecraft.client.renderer.texture.TextureMap; +import net.minecraft.entity.Entity; +import net.minecraft.util.AxisAlignedBB; +import net.minecraft.util.EnumWorldBlockLayer; +import net.minecraft.util.MathHelper; +import org.lwjgl.util.glu.Project; + +import static net.minecraft.client.renderer.GlStateManager.*; +import static org.lwjgl.opengl.GL11.*; + +public abstract class CustomEntityRenderer { + protected final Minecraft mc = Minecraft.getMinecraft(); + protected final EntityRenderer proxied = mc.entityRenderer; + protected int frameCount; + + protected void gluPerspective(float fovY, float aspect, float zNear, float zFar) { + Project.gluPerspective(fovY, aspect, zNear, zFar); + } + + public void updateCameraAndRender(float renderPartialTicks) { + if (mc.theWorld != null) { + renderWorld(renderPartialTicks, 0); + + if (OpenGlHelper.shadersSupported) { + mc.renderGlobal.renderEntityOutlineFramebuffer(); + + if (proxied.theShaderGroup != null && proxied.useShader) { + matrixMode(GL_TEXTURE); + pushMatrix(); + loadIdentity(); + proxied.theShaderGroup.loadShaderGroup(renderPartialTicks); + popMatrix(); + } + + mc.getFramebuffer().bindFramebuffer(true); + } + } + } + + protected void renderWorld(float partialTicks, long finishTimeNano) { + proxied.updateLightmap(partialTicks); + + enableDepth(); + enableAlpha(); + alphaFunc(516, 0.5F); + + renderWorldPass(partialTicks, finishTimeNano, 2); + } + + protected void renderWorldPass(float partialTicks, long finishTimeNano, int renderPass) { + RenderGlobal renderglobal = mc.renderGlobal; + EffectRenderer effectrenderer = mc.effectRenderer; + Entity entity = mc.getRenderViewEntity(); + enableCull(); + viewport(0, 0, mc.displayWidth, mc.displayHeight); + proxied.updateFogColor(partialTicks); + clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + setupCameraTransform(partialTicks); + ActiveRenderInfo.updateRenderInfo(mc.thePlayer, mc.gameSettings.thirdPersonView == 2); + ClippingHelperImpl.getInstance(); + ICamera camera = new NoCullingCamera(); + + if (this.mc.gameSettings.renderDistanceChunks >= 4) { + proxied.setupFog(-1, partialTicks); + matrixMode(GL_PROJECTION); + loadIdentity(); + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float) mc.displayWidth / mc.displayHeight, 0.05F, proxied.farPlaneDistance * 2.0F); + matrixMode(GL_MODELVIEW); + renderglobal.renderSky(partialTicks, renderPass); + matrixMode(GL_PROJECTION); + loadIdentity(); + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float) mc.displayWidth / mc.displayHeight, 0.05F, proxied.farPlaneDistance * MathHelper.SQRT_2); + matrixMode(GL_MODELVIEW); + } + + proxied.setupFog(0, partialTicks); + shadeModel(GL_SMOOTH); + + if (entity.posY + entity.getEyeHeight() < 128) { + renderCloudsCheck(renderglobal, partialTicks, renderPass); + } + + proxied.setupFog(0, partialTicks); + mc.getTextureManager().bindTexture(TextureMap.locationBlocksTexture); + RenderHelper.disableStandardItemLighting(); + renderglobal.setupTerrain(entity, partialTicks, camera, frameCount++, mc.thePlayer.isSpectator()); + + renderglobal.updateChunks(finishTimeNano); + + matrixMode(GL_MODELVIEW); + pushMatrix(); + disableAlpha(); + renderglobal.renderBlockLayer(EnumWorldBlockLayer.SOLID, partialTicks, renderPass, entity); + enableAlpha(); + renderglobal.renderBlockLayer(EnumWorldBlockLayer.CUTOUT_MIPPED, partialTicks, renderPass, entity); + mc.getTextureManager().getTexture(TextureMap.locationBlocksTexture).setBlurMipmap(false, false); + renderglobal.renderBlockLayer(EnumWorldBlockLayer.CUTOUT, partialTicks, renderPass, entity); + mc.getTextureManager().getTexture(TextureMap.locationBlocksTexture).restoreLastBlurMipmap(); + shadeModel(GL_FLAT); + alphaFunc(516, 0.1F); + + matrixMode(GL_MODELVIEW); + popMatrix(); + pushMatrix(); + RenderHelper.enableStandardItemLighting(); + + net.minecraftforge.client.ForgeHooksClient.setRenderPass(0); + renderglobal.renderEntities(entity, camera, partialTicks); + net.minecraftforge.client.ForgeHooksClient.setRenderPass(0); + RenderHelper.disableStandardItemLighting(); + proxied.disableLightmap(); + matrixMode(GL_MODELVIEW); + popMatrix(); + + enableBlend(); + tryBlendFuncSeparate(770, 1, 1, 0); + renderglobal.drawBlockDamageTexture(Tessellator.getInstance(), Tessellator.getInstance().getWorldRenderer(), entity, partialTicks); + disableBlend(); + + proxied.enableLightmap(); + effectrenderer.renderLitParticles(entity, partialTicks); + RenderHelper.disableStandardItemLighting(); + proxied.setupFog(0, partialTicks); + effectrenderer.renderParticles(entity, partialTicks); + proxied.disableLightmap(); + + depthMask(false); + enableCull(); + proxied.renderRainSnow(partialTicks); + depthMask(true); + renderglobal.renderWorldBorder(entity, partialTicks); + disableBlend(); + enableCull(); + tryBlendFuncSeparate(770, 771, 1, 0); + alphaFunc(516, 0.1F); + proxied.setupFog(0, partialTicks); + enableBlend(); + depthMask(false); + mc.getTextureManager().bindTexture(TextureMap.locationBlocksTexture); + shadeModel(GL_SMOOTH); + + if (mc.gameSettings.fancyGraphics) { + enableBlend(); + tryBlendFuncSeparate(770, 771, 1, 0); + renderglobal.renderBlockLayer(EnumWorldBlockLayer.TRANSLUCENT, partialTicks, renderPass, entity); + disableBlend(); + } else { + renderglobal.renderBlockLayer(EnumWorldBlockLayer.TRANSLUCENT, partialTicks, renderPass, entity); + } + + RenderHelper.enableStandardItemLighting(); + net.minecraftforge.client.ForgeHooksClient.setRenderPass(1); + renderglobal.renderEntities(entity, camera, partialTicks); + net.minecraftforge.client.ForgeHooksClient.setRenderPass(-1); + RenderHelper.disableStandardItemLighting(); + + shadeModel(GL_FLAT); + depthMask(true); + enableCull(); + disableBlend(); + disableFog(); + + if (entity.posY + entity.getEyeHeight() >= 128) { + renderCloudsCheck(renderglobal, partialTicks, renderPass); + } + + net.minecraftforge.client.ForgeHooksClient.dispatchRenderLast(renderglobal, partialTicks); + } + + protected void setupCameraTransform(float partialTicks) { + proxied.farPlaneDistance = (float)(this.mc.gameSettings.renderDistanceChunks * 16); + + matrixMode(GL_PROJECTION); + loadIdentity(); + + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float) this.mc.displayWidth / (float) this.mc.displayHeight, 0.05F, proxied.farPlaneDistance * MathHelper.SQRT_2); + + matrixMode(GL_MODELVIEW); + loadIdentity(); + + orientCamera(partialTicks); + } + + protected void orientCamera(float partialTicks) { + proxied.orientCamera(partialTicks); + } + + protected void renderCloudsCheck(RenderGlobal renderglobal, float partialTicks, int renderPass) { + if (this.mc.gameSettings.shouldRenderClouds()) { + matrixMode(GL_PROJECTION); + loadIdentity(); + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float) this.mc.displayWidth / (float) this.mc.displayHeight, 0.05F, proxied.farPlaneDistance * 4.0F); + matrixMode(GL_MODELVIEW); + pushMatrix(); + proxied.setupFog(0, partialTicks); + renderglobal.renderClouds(partialTicks, renderPass); + disableFog(); + popMatrix(); + matrixMode(GL_PROJECTION); + loadIdentity(); + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float)this.mc.displayWidth / (float)this.mc.displayHeight, 0.05F, proxied.farPlaneDistance * MathHelper.SQRT_2); + matrixMode(GL_MODELVIEW); + } + } + + public static final class NoCullingCamera implements ICamera { + + @Override + public boolean isBoundingBoxInFrustum(AxisAlignedBB p_78546_1_) { + return true; + } + + @Override + public void setPosition(double p_78547_1_, double p_78547_3_, double p_78547_5_) { + + } + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/entity/StereoscopicEntityRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/entity/StereoscopicEntityRenderer.java new file mode 100644 index 00000000..b984de46 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/entity/StereoscopicEntityRenderer.java @@ -0,0 +1,39 @@ +package eu.crushedpixel.replaymod.video.entity; + +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.util.MathHelper; + +import static net.minecraft.client.renderer.GlStateManager.loadIdentity; +import static net.minecraft.client.renderer.GlStateManager.matrixMode; +import static org.lwjgl.opengl.GL11.GL_MODELVIEW; +import static org.lwjgl.opengl.GL11.GL_PROJECTION; + +public class StereoscopicEntityRenderer extends CustomEntityRenderer { + + private boolean leftEye; + + public void setEye(boolean leftEye) { + this.leftEye = leftEye; + } + + protected void translateStereoscopic() { + GlStateManager.translate(leftEye ? 0.07 : -0.07, 0, 0); + } + + @Override + protected void setupCameraTransform(float partialTicks) { + proxied.farPlaneDistance = (float)(this.mc.gameSettings.renderDistanceChunks * 16); + + matrixMode(GL_PROJECTION); + loadIdentity(); + translateStereoscopic(); + + gluPerspective(proxied.getFOVModifier(partialTicks, true), (float) this.mc.displayWidth / (float) this.mc.displayHeight, 0.05F, proxied.farPlaneDistance * MathHelper.SQRT_2); + + matrixMode(GL_MODELVIEW); + loadIdentity(); + translateStereoscopic(); + + orientCamera(partialTicks); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/entity/TilingEntityRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/entity/TilingEntityRenderer.java new file mode 100644 index 00000000..4deaec11 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/entity/TilingEntityRenderer.java @@ -0,0 +1,41 @@ +package eu.crushedpixel.replaymod.video.entity; + +import eu.crushedpixel.replaymod.video.frame.TilingFrameRenderer; + +import static net.minecraft.client.renderer.GlStateManager.scale; +import static net.minecraft.client.renderer.GlStateManager.translate; +import static org.lwjgl.opengl.GL11.glFrustum; + +public class TilingEntityRenderer extends CustomEntityRenderer { + private final TilingFrameRenderer renderer; + private int tileX, tileY; + + public TilingEntityRenderer(TilingFrameRenderer renderer) { + this.renderer = renderer; + } + + public void setTile(int tileX, int tileY) { + this.tileX = tileX; + this.tileY = tileY; + } + + @Override + public void gluPerspective(float fovY, float aspect, float zNear, float zFar) { + double height = Math.tan(fovY / 360 * Math.PI) * zNear; + double width = height * aspect; + + int videoWidth = renderer.getVideoWidth(); + int videoHeight = renderer.getVideoHeight(); + int tileWidth = renderer.getTileWidth(); + int tileHeight = renderer.getTileHeight(); + double tilesX = (double) videoWidth / tileWidth; + double tilesY = (double) videoHeight / tileHeight; + double tilesMin = Math.min(tilesX, tilesY); + scale(tilesMin, tilesMin, 1); + double xOffset = (2 * tileX - tilesX + 1) / tilesMin; + double yOffset = (2 * tileY - tilesY + 1) / tilesMin; + translate(-xOffset, yOffset, 0); + + glFrustum(-width, width, -height, height, zNear, zFar); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/CubicFrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/CubicFrameRenderer.java new file mode 100644 index 00000000..cbaccbeb --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/CubicFrameRenderer.java @@ -0,0 +1,79 @@ +package eu.crushedpixel.replaymod.video.frame; + +import eu.crushedpixel.replaymod.video.entity.CubicEntityRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.crash.CrashReport; +import net.minecraft.util.ReportedException; +import net.minecraft.util.Timer; + +import java.awt.image.BufferedImage; + +public class CubicFrameRenderer extends FrameRenderer { + protected static int getDisplaySize() { + Minecraft mc = Minecraft.getMinecraft(); + return Math.min(mc.displayWidth, mc.displayHeight); + } + + protected final CubicEntityRenderer entityRenderer; + protected final int displaySize; + + protected CubicFrameRenderer(int videoWidth, int videoHeight, boolean stable) { + super(videoWidth, videoHeight, getDisplaySize() * getDisplaySize() * 3); + this.displaySize = getDisplaySize(); + this.entityRenderer = new CubicEntityRenderer(stable); + setCustomEntityRenderer(entityRenderer); + } + + public CubicFrameRenderer(boolean stable) { + this(getDisplaySize() * 4, getDisplaySize() * 3, stable); + } + + @Override + public BufferedImage captureFrame(Timer timer) { + BufferedImage image = new BufferedImage(getVideoWidth(), getVideoHeight(), BufferedImage.TYPE_INT_RGB); + for (CubicEntityRenderer.Direction direction : CubicEntityRenderer.Direction.values()) { + try { + captureFrame(timer, image, direction); + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Rendering frame " + direction + "."); + throw new ReportedException(crash); + } + } + updateDefaultPreview(image); + return image; + } + + protected void captureFrame(Timer timer, BufferedImage into, CubicEntityRenderer.Direction direction) { + renderFrameToBuffer(timer, direction); + + // Copy to image + int frame = direction.getCubicFrame(); + int xVideo = frame % 4 * displaySize; + int yVideo = frame / 4 * displaySize; + copyPixelsToImage(buffer, displaySize, into, xVideo, yVideo); + } + + protected void renderFrameToBuffer(Timer timer, CubicEntityRenderer.Direction direction) { + // Setup aspect ratio + int originalWidth = mc.displayWidth; + int originalHeight = mc.displayHeight; + mc.displayWidth = mc.displayHeight = displaySize; + updateFrameBufferSize(); + + // Render frame + entityRenderer.setDirection(direction); + renderFrame(timer); + + // Read pixels + readPixels(buffer); + + mc.displayWidth = originalWidth; + mc.displayHeight = originalHeight; + updateFrameBufferSize(); + } + + private void updateFrameBufferSize() { + mc.getFramebuffer().createBindFramebuffer(mc.displayWidth, mc.displayHeight); + mc.entityRenderer.updateShaderGroupSize(mc.displayWidth, mc.displayHeight); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/DefaultFrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/DefaultFrameRenderer.java new file mode 100644 index 00000000..0bef054e --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/DefaultFrameRenderer.java @@ -0,0 +1,27 @@ +package eu.crushedpixel.replaymod.video.frame; + +import net.minecraft.client.Minecraft; +import net.minecraft.util.Timer; + +import java.awt.image.BufferedImage; + +public class DefaultFrameRenderer extends FrameRenderer { + + public DefaultFrameRenderer() { + super(Minecraft.getMinecraft().displayWidth, Minecraft.getMinecraft().displayHeight); + } + + @Override + public BufferedImage captureFrame(Timer timer) { + renderFrame(timer); + + int width = getVideoWidth(); + int height = getVideoHeight(); + + readPixels(buffer); + + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + copyPixelsToImage(buffer, width, image, 0, 0); + return image; + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/EquirectangularFrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/EquirectangularFrameRenderer.java new file mode 100644 index 00000000..5fbbb784 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/EquirectangularFrameRenderer.java @@ -0,0 +1,101 @@ +package eu.crushedpixel.replaymod.video.frame; + +import eu.crushedpixel.replaymod.video.entity.CubicEntityRenderer; +import net.minecraft.crash.CrashReport; +import net.minecraft.util.ReportedException; +import net.minecraft.util.Timer; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; + +import static java.lang.Math.PI; + +public class EquirectangularFrameRenderer extends CubicFrameRenderer { + + public EquirectangularFrameRenderer(boolean stable) { + super(getDisplaySize() * 4, getDisplaySize() * 2, stable); + } + + @Override + public BufferedImage captureFrame(Timer timer) { + BufferedImage top = captureFrame(timer, CubicEntityRenderer.Direction.TOP); + BufferedImage bottom = captureFrame(timer, CubicEntityRenderer.Direction.BOTTOM); + BufferedImage front = captureFrame(timer, CubicEntityRenderer.Direction.FRONT); + BufferedImage back = captureFrame(timer, CubicEntityRenderer.Direction.BACK); + BufferedImage left = captureFrame(timer, CubicEntityRenderer.Direction.LEFT); + BufferedImage right = captureFrame(timer, CubicEntityRenderer.Direction.RIGHT); + try { + BufferedImage result = cubicToEquirectangular(top, bottom, front, back, left, right); + updateDefaultPreview(result); + return result; + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Transforming cubic to equirectangular image."); + throw new ReportedException(crash); + } + } + + protected BufferedImage cubicToEquirectangular(BufferedImage top, BufferedImage bottom, BufferedImage front, BufferedImage back, BufferedImage left, BufferedImage right) { + int rWidth = getVideoWidth(); + int rHeight = getVideoHeight(); + BufferedImage result = new BufferedImage(rWidth, rHeight, BufferedImage.TYPE_INT_RGB); + int[] resultPixels = ((DataBufferInt) result.getRaster().getDataBuffer()).getData(); + for (int i = 0; i < rWidth; i++) { + double yaw = PI * 2 * i / rWidth; + int piQuarter = 8 * i / rWidth - 4; + BufferedImage target; + if (piQuarter < -3) { + target = back; + } else if (piQuarter < -1) { + target = left; + } else if (piQuarter < 1) { + target = front; + } else if (piQuarter < 3) { + target = right; + } else { + target = back; + } + double fYaw = (yaw + PI/4) % (PI / 2) - PI/4; + double d = 1 / Math.cos(fYaw); + double gcXN = (Math.tan(fYaw) + 1) / 2; + for (int j = 0; j < rHeight; j++) { + double cXN = gcXN; + BufferedImage pt = target; + double pitch = PI * j / rHeight - PI / 2; + double cYN = (Math.tan(pitch) * d + 1) / 2; + + if (cYN >= 1) { + double pd = Math.tan(PI/2 - pitch); + cXN = (-Math.sin(yaw) * pd + 1) / 2; + cYN = (Math.cos(yaw) * pd + 1) / 2; + pt = bottom; + } + if (cYN < 0) { + double pd = Math.tan(PI/2 - pitch); + cXN = (Math.sin(yaw) * pd + 1) / 2; + cYN = (Math.cos(yaw) * pd + 1) / 2; + pt = top; + } + + int cX = Math.min(displaySize - 1, (int) (cXN * displaySize)); + int cY = Math.min(displaySize - 1, (int) (cYN * displaySize)); + resultPixels[i + j * rWidth] = 0xff000000 | pt.getRGB(cX, cY); // Make sure we got alpha value as well + } + } + + return result; + } + + protected BufferedImage captureFrame(Timer timer, CubicEntityRenderer.Direction direction) { + try { + renderFrameToBuffer(timer, direction); + + // Copy to image + BufferedImage image = new BufferedImage(displaySize, displaySize, BufferedImage.TYPE_INT_RGB); + copyPixelsToImage(buffer, displaySize, image, displaySize, 0, 0); + return image; + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Rendering frame " + direction); + throw new ReportedException(crash); + } + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/FrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/FrameRenderer.java new file mode 100644 index 00000000..7006f4fb --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/FrameRenderer.java @@ -0,0 +1,225 @@ +package eu.crushedpixel.replaymod.video.frame; + +import com.google.common.base.Optional; +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.Runnables; +import eu.crushedpixel.replaymod.gui.GuiVideoRenderer; +import eu.crushedpixel.replaymod.video.entity.CustomEntityRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.client.renderer.texture.TextureUtil; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.Timer; +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import static net.minecraft.client.renderer.GlStateManager.*; +import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; +import static org.lwjgl.opengl.GL11.GL_DEPTH_BUFFER_BIT; + +public abstract class FrameRenderer { + private static final ResourceLocation noPreviewTexture = new ResourceLocation("replaymod", "logo.jpg"); + protected final Minecraft mc = Minecraft.getMinecraft(); + private final int videoWidth; + private final int videoHeight; + private Optional customEntityRenderer; + private DynamicTexture previewTexture; + private Runnable renderPreviewCallback; + private boolean previewActive = false; + protected final ByteBuffer buffer; + + public FrameRenderer(int videoWidth, int videoHeight) { + this.videoWidth = videoWidth; + this.videoHeight = videoHeight; + this.buffer = BufferUtils.createByteBuffer(mc.displayWidth * mc.displayHeight * 3); + this.customEntityRenderer = Optional.absent(); + } + + public FrameRenderer(int videoWidth, int videoHeight, int bufferSize) { + this.videoWidth = videoWidth; + this.videoHeight = videoHeight; + this.buffer = BufferUtils.createByteBuffer(bufferSize); + this.customEntityRenderer = Optional.absent(); + } + + public final int getVideoWidth() { + return videoWidth; + } + + public final int getVideoHeight() { + return videoHeight; + } + + public void setPreviewActive(boolean previewActive) { + this.previewActive = previewActive; + if (previewActive) { + Arrays.fill(previewTexture.getTextureData(), 0xff000000); + previewTexture.updateDynamicTexture(); + } + } + + public boolean isPreviewActive() { + return previewActive; + } + + public boolean setRenderPreviewCallback(Runnable renderPreviewCallback) { + Preconditions.checkState(this.renderPreviewCallback == null, "Render preview callback already set."); + this.renderPreviewCallback = renderPreviewCallback; + return false; + } + + public Runnable getRenderPreviewCallback() { + return renderPreviewCallback != null ? renderPreviewCallback : Runnables.doNothing(); + } + + public void setCustomEntityRenderer(CustomEntityRenderer customEntityRenderer) { + this.customEntityRenderer = Optional.fromNullable(customEntityRenderer); + } + + public abstract BufferedImage captureFrame(Timer timer); + + protected void readPixels(ByteBuffer buffer) { + buffer.clear(); + GL11.glPixelStorei(GL11.GL_PACK_ALIGNMENT, 1); + GL11.glPixelStorei(GL11.GL_UNPACK_ALIGNMENT, 1); + GL11.glReadPixels(0, 0, mc.displayWidth, mc.displayHeight, GL11.GL_RGB, GL11.GL_UNSIGNED_BYTE, buffer); + buffer.rewind(); + } + + protected void updateDefaultPreview(BufferedImage image) { + if (isPreviewActive()) { + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + updateDefaultPreview(pixels); + } + } + + protected void updateDefaultPreview(int[] pixels) { + if (isPreviewActive()) { + System.arraycopy(pixels, 0, getPreviewTexture().getTextureData(), 0, pixels.length); + getPreviewTexture().updateDynamicTexture(); + } + } + + protected void copyPixelsToImage(ByteBuffer buffer, int bufferWidth, BufferedImage image, int offsetX, int offsetY) { + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + copyPixelsToImage(buffer, bufferWidth, pixels, offsetX, offsetY); + } + + protected void copyPixelsToImage(ByteBuffer buffer, int bufferWidth, BufferedImage image, int imageWidth, int offsetX, int offsetY) { + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + copyPixelsToImage(buffer, bufferWidth, pixels, imageWidth, offsetX, offsetY); + } + + protected void copyPixelsToImage(ByteBuffer buffer, int bufferWidth, int[] image, int offsetX, int offsetY) { + copyPixelsToImage(buffer, bufferWidth, image, videoWidth, offsetX, offsetY); + } + + protected void copyPixelsToImage(ByteBuffer buffer, int bufferWidth, int[] image, int imageWidth, int offsetX, int offsetY) { + int bufferSize = buffer.remaining() / 3; + // Read the OpenGL image row by row from right to left (flipped horizontally) + for (int i = bufferSize - 1; i >= 0; i--) { + // Coordinates in the final image + int x = offsetX + bufferWidth - i % bufferWidth - 1; // X coord of OpenGL image has to be flipped first + int y = offsetY + i / bufferWidth; + if (x >= imageWidth || y * imageWidth >= image.length) { + buffer.position(buffer.position() + 3); // Pixel would end up outside of image + continue; + } + // Write to image (row by row, left to right) + image[y * imageWidth + x] = 0xff << 24 | (buffer.get() & 0xff) << 16 | (buffer.get() & 0xff) << 8 | buffer.get() & 0xff; + } + buffer.rewind(); + } + + protected void renderFrame(Timer timer) { + pushMatrix(); + clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + mc.getFramebuffer().bindFramebuffer(true); + + enableTexture2D(); + + if (customEntityRenderer.isPresent()) { + customEntityRenderer.get().updateCameraAndRender(timer.renderPartialTicks); + } else { + mc.entityRenderer.updateCameraAndRender(timer.renderPartialTicks); + } + + mc.getFramebuffer().unbindFramebuffer(); + popMatrix(); + + pushMatrix(); + mc.getFramebuffer().framebufferRender(mc.displayWidth, mc.displayHeight); + popMatrix(); + } + + public void setup() { + this.previewTexture = new DynamicTexture(videoWidth, videoHeight) { + @Override + public void updateDynamicTexture() { + bindTexture(getGlTextureId()); + TextureUtil.uploadTextureSub(0, getTextureData(), getVideoWidth(), getVideoHeight(), 0, 0, true, false, false); + } + }; + } + + public void tearDown() { + this.previewTexture.deleteGlTexture(); + } + + public DynamicTexture getPreviewTexture() { + return previewTexture; + } + + /** + * Return the height including any borders of the preview. + * No rendering should be performed by {@link #renderPreview(GuiVideoRenderer)} below this height. + * @param guiScreen The gui screen + * @return Bottom of the preview + */ + public int getPreviewHeight(GuiScreen guiScreen) { + int width = guiScreen.width / 2; + int height = guiScreen.height / 3; + if (width / height < getVideoWidth() / getVideoHeight()) { + height = width * getVideoHeight() / getVideoWidth(); + } + return guiScreen.height / 2 + height + 10; + } + + /** + * Draw a preview of the current scene on the lower half of the screen. + * @param guiScreen The gui screen + */ + public void renderPreview(GuiVideoRenderer guiScreen) { + int width = guiScreen.width / 2; + int height = guiScreen.height / 3; + if (width / height > getVideoWidth() / getVideoHeight()) { + width = height * getVideoWidth() / getVideoHeight(); + } else { + height = width * getVideoHeight() / getVideoWidth(); + } + + int x = guiScreen.width / 2 - width / 2; + int y = guiScreen.height / 2 + 5; + + bindTexture(previewTexture.getGlTextureId()); + color(1, 1, 1, 1); + Gui.drawScaledCustomSizeModalRect(x, y, 0, 0, videoWidth, videoHeight, width, height, videoWidth, videoHeight); + } + + public static void renderNoPreview(int guiWidth, int guiHeight, int height) { + int width = height * 1280 / 720; + int x = guiWidth / 2 - width / 2; + int y = guiHeight / 2 + 5; + + Minecraft.getMinecraft().getTextureManager().bindTexture(noPreviewTexture); + color(1, 1, 1, 1); + Gui.drawScaledCustomSizeModalRect(x, y, 0, 0, 1280, 720, width, height, 1280, 720); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/StereoscopicFrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/StereoscopicFrameRenderer.java new file mode 100644 index 00000000..77d91371 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/StereoscopicFrameRenderer.java @@ -0,0 +1,51 @@ +package eu.crushedpixel.replaymod.video.frame; + +import eu.crushedpixel.replaymod.video.entity.StereoscopicEntityRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.crash.CrashReport; +import net.minecraft.util.ReportedException; +import net.minecraft.util.Timer; + +import java.awt.image.BufferedImage; + +public class StereoscopicFrameRenderer extends FrameRenderer { + private final StereoscopicEntityRenderer entityRenderer = new StereoscopicEntityRenderer(); + + public StereoscopicFrameRenderer() { + super(Minecraft.getMinecraft().displayWidth * 2, Minecraft.getMinecraft().displayHeight); + setCustomEntityRenderer(entityRenderer); + } + + @Override + public BufferedImage captureFrame(Timer timer) { + BufferedImage image = new BufferedImage(getVideoWidth(), getVideoHeight(), BufferedImage.TYPE_INT_RGB); + try { + captureFrame(timer, image, true); + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Rendering left eye."); + throw new ReportedException(crash); + } + try { + captureFrame(timer, image, false); + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Rendering right eye."); + throw new ReportedException(crash); + } + return image; + } + + protected void captureFrame(Timer timer, BufferedImage into, boolean leftEye) { + int displayWidth = getVideoWidth() / 2; + + // Render frame + entityRenderer.setEye(leftEye); + renderFrame(timer); + + // Read pixels + readPixels(buffer); + + // Copy to image + copyPixelsToImage(buffer, displayWidth, into, leftEye ? 0 : displayWidth, 0); + updateDefaultPreview(into); + } +} diff --git a/src/main/java/eu/crushedpixel/replaymod/video/frame/TilingFrameRenderer.java b/src/main/java/eu/crushedpixel/replaymod/video/frame/TilingFrameRenderer.java new file mode 100644 index 00000000..a61f8816 --- /dev/null +++ b/src/main/java/eu/crushedpixel/replaymod/video/frame/TilingFrameRenderer.java @@ -0,0 +1,130 @@ +package eu.crushedpixel.replaymod.video.frame; + +import eu.crushedpixel.replaymod.video.entity.TilingEntityRenderer; +import net.minecraft.crash.CrashReport; +import net.minecraft.util.ReportedException; +import net.minecraft.util.Timer; + +import java.awt.image.BufferedImage; +import java.util.Arrays; + +public class TilingFrameRenderer extends FrameRenderer { + private final TilingEntityRenderer entityRenderer = new TilingEntityRenderer(this); + private final int tileWidth; + private final int tileHeight; + + public TilingFrameRenderer(int videoWidth, int videoHeight) { + super(videoWidth, videoHeight); + this.tileWidth = mc.displayWidth; + this.tileHeight = mc.displayHeight; + setCustomEntityRenderer(entityRenderer); + } + + @Override + public boolean setRenderPreviewCallback(Runnable renderPreviewCallback) { + super.setRenderPreviewCallback(renderPreviewCallback); + return true; + } + + @Override + public BufferedImage captureFrame(Timer timer) { + BufferedImage image = new BufferedImage(getVideoWidth(), getVideoHeight(), BufferedImage.TYPE_INT_RGB); + int tilesX = getTilesX(); + int tilesY = getTilesY(); + for (int x = 0; x < tilesX; x++) { + for (int y = 0; y < tilesY; y++) { + try { + captureTile(timer, x, y, image); + } catch (Throwable t) { + CrashReport crash = CrashReport.makeCrashReport(t, "Rendering tile " + x + "/" + y); + throw new ReportedException(crash); + } + getRenderPreviewCallback().run(); + } + } + return image; + } + + protected void captureTile(Timer timer, int tileX, int tileY, BufferedImage into) { + // Render frame + entityRenderer.setTile(tileX, tileY); + renderFrame(timer); + + // Read pixels + readPixels(buffer); + + // Copy to image + copyPixelsToImage(buffer, tileWidth, into, tileX * tileWidth, tileY * tileHeight); + if (isPreviewActive()) { + int[] pixels = getPreviewTexture().getTextureData(); + copyPixelsToImage(buffer, tileWidth, pixels, tileX * tileWidth, tileY * tileHeight); + + // Draw red rectangle around next tile + tileY = (tileY + 1) % getTilesY(); + if (tileY == 0) { + tileX = (tileX + 1) % getTilesX(); + } + drawPreviewRectangle(pixels, tileX, tileY); + + getPreviewTexture().updateDynamicTexture(); + } + } + + private void drawPreviewRectangle(int[] pixels, int tileX, int tileY) { + final int RED = 0xffff0000; + + int videoWidth = getVideoWidth(); + int videoHeight = getVideoHeight(); + int border = videoWidth / tileWidth * 5; + int left = Math.min(tileX * tileWidth, videoWidth - border - 1); + int right = Math.min((left + tileWidth), videoWidth) - 1; + int top = Math.min(tileY * tileHeight, videoHeight - border - 1); + int bottom = Math.min((top + tileHeight), videoHeight - border) - 1; + + for (int i = 0; i < border; i++) { + // Top border + int offset = (top + i) * videoWidth; + Arrays.fill(pixels, offset + left, offset + right + 1, RED); + + // Bottom border + offset = (bottom - i) * videoWidth; + Arrays.fill(pixels, offset + left, offset + right + 1, RED); + + // Left border + for (int j = top; j <= bottom; j++) { + pixels[j * videoWidth + left + i] = RED; + } + + // Right border + for (int j = top; j <= bottom; j++) { + pixels[j * videoWidth + right - i] = RED; + } + } + } + + public int getTilesX() { + int videoWidth = getVideoWidth(); + if (videoWidth % tileWidth == 0) { + return videoWidth / tileWidth; + } else { + return videoWidth / tileWidth + 1; + } + } + + public int getTilesY() { + int videoHeight = getVideoHeight(); + if (videoHeight % tileHeight == 0) { + return videoHeight / tileHeight; + } else { + return videoHeight / tileHeight + 1; + } + } + + public int getTileWidth() { + return tileWidth; + } + + public int getTileHeight() { + return tileHeight; + } +} diff --git a/src/main/resources/META-INF/replaymod_at.cfg b/src/main/resources/META-INF/replaymod_at.cfg index 1174c284..6d654330 100644 --- a/src/main/resources/META-INF/replaymod_at.cfg +++ b/src/main/resources/META-INF/replaymod_at.cfg @@ -38,6 +38,35 @@ public net.minecraft.client.resources.ResourcePackRepository field_148534_e # di # EntityRenderer public net.minecraft.client.renderer.EntityRenderer field_147711_ac # resourceManager +public net.minecraft.client.renderer.EntityRenderer field_147707_d # theShaderGroup +public net.minecraft.client.renderer.EntityRenderer field_175083_ad # useShader +public net.minecraft.client.renderer.EntityRenderer field_78530_s # farPlaneDistance + +public net.minecraft.client.renderer.EntityRenderer func_78467_g(F)V # orientCamera +public net.minecraft.client.renderer.EntityRenderer func_78474_d(F)V # renderRainSnow +public net.minecraft.client.renderer.EntityRenderer func_78481_a(FZ)F # getFOVModifier +public net.minecraft.client.renderer.EntityRenderer func_78468_a(IF)V # setupFog +public net.minecraft.client.renderer.EntityRenderer func_78466_h(F)V # updateFogColor +public net.minecraft.client.renderer.EntityRenderer func_78472_g(F)V # updateLightmap + +# RenderGlobal +public net.minecraft.client.renderer.RenderGlobal field_174995_M # renderDispatcher +public net.minecraft.client.renderer.RenderGlobal field_147595_R # displayListEntitiesDirty +public net.minecraft.client.renderer.RenderGlobal field_175009_l # chunksToUpdate +public net.minecraft.client.renderer.RenderGlobal field_72755_R # renderInfos +public net.minecraft.client.renderer.RenderGlobal field_175009_l # chunksToUpdate +public net.minecraft.client.renderer.RenderGlobal func_174983_a(Lnet.minecraft.util.BlockPos;Lnet.minecraft.client.renderer.chunk.RenderChunk;)Z # isPositionInRenderChunk + +public net.minecraft.client.renderer.RenderGlobal$ContainerLocalRenderInformation +public net.minecraft.client.renderer.RenderGlobal$ContainerLocalRenderInformation field_178553_a # renderChunk + +# ChunkRenderDispatcher +public-f net.minecraft.client.renderer.chunk.ChunkRenderDispatcher field_178519_d # queueChunkUpdates +public net.minecraft.client.renderer.chunk.ChunkRenderDispatcher field_178524_h # queueChunkUploads +public net.minecraft.client.renderer.chunk.ChunkRenderDispatcher field_178522_c # listThreadedWorkers + +# TextureUtil +public net.minecraft.client.renderer.texture.TextureUtil func_147947_a(I[IIIIIZZZ)V # uploadTextureSub #RenderManager public net.minecraft.client.renderer.entity.RenderManager field_178636_l # skinMap diff --git a/src/main/resources/assets/replaymod/logo.jpg b/src/main/resources/assets/replaymod/logo.jpg new file mode 100755 index 0000000000000000000000000000000000000000..fbe127624187dbc22bb1c99e42ff41875abcb90a GIT binary patch literal 27743 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 d^U(AZ^)Uc^S;GKC!vCYMWB