Add OpenEXR export option

In preparation for depth map export.
This commit is contained in:
Jonas Herzig
2020-10-03 19:38:13 +02:00
parent 17e554b67c
commit 2b6d1b2359
10 changed files with 209 additions and 11 deletions

View File

@@ -357,6 +357,12 @@ There are **7 Encoding Presets** you can choose from:
- **PNG Sequence** - **PNG Sequence**
Exports the sequence as individual frames in the **PNG Format**. Exports the sequence as individual frames in the **PNG Format**.
**Warning:** This can create a huge amount of files, so make sure to save them in a separate folder. **Warning:** This can create a huge amount of files, so make sure to save them in a separate folder.
- **OpenEXR Sequence** (Minecraft 1.14 and above)
Exports the sequence as individual frames in the **OpenEXR Format**.
The images are of perfect quality and may contain
additional image layers beyond the visible one (e.g. [depth](#replaying-render-advanced-depth-map)).
As such, this format is a good choice if you have plenty of disk space to spare and want to perform additional editing
on the video in a third-party video editor.
### Advanced Settings [advanced] ### Advanced Settings [advanced]
![](img/rendersettings-advanced.jpg) ![](img/rendersettings-advanced.jpg)

View File

@@ -0,0 +1,108 @@
//#if MC>=11400
package com.replaymod.render;
import com.replaymod.core.versions.MCVer;
import com.replaymod.render.frame.EXRFrame;
import com.replaymod.render.rendering.FrameConsumer;
import com.replaymod.render.utils.ByteBufferPool;
import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension;
import net.minecraft.util.crash.CrashReport;
import org.lwjgl.PointerBuffer;
import org.lwjgl.util.tinyexr.EXRChannelInfo;
import org.lwjgl.util.tinyexr.EXRHeader;
import org.lwjgl.util.tinyexr.EXRImage;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.lwjgl.system.MemoryStack.*;
import static org.lwjgl.system.MemoryUtil.*;
import static org.lwjgl.util.tinyexr.TinyEXR.*;
public class EXRWriter implements FrameConsumer<EXRFrame> {
private final Path outputFolder;
public EXRWriter(Path outputFolder) throws IOException {
this.outputFolder = outputFolder;
Files.createDirectories(outputFolder);
}
@Override
public void consume(EXRFrame frame) {
Path path = outputFolder.resolve(frame.getFrameId() + ".exr");
ReadableDimension size = frame.getSize();
ByteBuffer bgra = frame.getBgraBuffer();
int width = size.getWidth();
int height = size.getHeight();
int numChannels = 3;
stackPush();
EXRHeader header = EXRHeader.mallocStack(); InitEXRHeader(header);
EXRChannelInfo.Buffer channelInfos = EXRChannelInfo.mallocStack(numChannels);
IntBuffer pixelTypes = stackMallocInt(numChannels);
IntBuffer requestedPixelTypes = stackMallocInt(numChannels);
EXRImage image = EXRImage.mallocStack(); InitEXRImage(image);
PointerBuffer imagePointers = stackMallocPointer(numChannels);
FloatBuffer images = memAllocFloat(width * height * numChannels);
PointerBuffer err = stackMallocPointer(1);
try {
header.num_channels(numChannels);
header.channels(channelInfos);
header.pixel_types(pixelTypes);
header.requested_pixel_types(requestedPixelTypes);
// Some readers ignore this, so we use the most expected order
memASCII("B", true, channelInfos.get(0).name());
memASCII("G", true, channelInfos.get(1).name());
memASCII("R", true, channelInfos.get(2).name());
for (int i = 0; i < numChannels; i++) {
pixelTypes.put(i, TINYEXR_PIXELTYPE_FLOAT);
requestedPixelTypes.put(i, TINYEXR_PIXELTYPE_HALF);
}
image.num_channels(numChannels);
image.width(width);
image.height(height);
image.images(imagePointers);
FloatBuffer[] bgrChannels = new FloatBuffer[3];
for (int i = 0; i < numChannels; i++) {
FloatBuffer channel = images.slice();
channel.position(width * height * i);
imagePointers.put(i, channel.slice());
bgrChannels[i] = channel;
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
for (FloatBuffer channel : bgrChannels) {
channel.put(((int) bgra.get() & 0xff) / 255f);
}
bgra.get(); // alpha
}
}
int ret = SaveEXRImageToFile(image, header, path.toString(), err);
if (ret != TINYEXR_SUCCESS) {
String message = err.getStringASCII(0);
FreeEXRErrorMessage(err.getByteBuffer(0));
throw new IOException(message);
}
} catch (Throwable t) {
MCVer.getMinecraft().setCrashReport(CrashReport.create(t, "Exporting EXR frame"));
} finally {
memFree(images);
stackPop();
ByteBufferPool.release(bgra);
}
}
@Override
public void close() {
}
}
//#endif

View File

@@ -79,6 +79,8 @@ public class RenderSettings {
BLEND(null, "blend"), BLEND(null, "blend"),
EXR(null, "exr"),
PNG("\"%FILENAME%-%06d.png\"", "png"); PNG("\"%FILENAME%-%06d.png\"", "png");
private final String preset; private final String preset;
@@ -111,6 +113,13 @@ public class RenderSettings {
public boolean isSupported() { public boolean isSupported() {
if (this == BLEND) { if (this == BLEND) {
return RenderMethod.BLEND.isSupported(); return RenderMethod.BLEND.isSupported();
} else if (this == EXR) {
// Need LJWGL 3
//#if MC>=11400
return true;
//#else
//$$ return false;
//#endif
} else { } else {
return true; return true;
} }

View File

@@ -0,0 +1,34 @@
package com.replaymod.render.frame;
import com.replaymod.render.rendering.Frame;
import de.johni0702.minecraft.gui.utils.lwjgl.ReadableDimension;
import org.apache.commons.lang3.Validate;
import java.nio.ByteBuffer;
public class EXRFrame implements Frame {
private final int frameId;
private final ReadableDimension size;
private final ByteBuffer bgraBuffer;
public EXRFrame(int frameId, ReadableDimension size, ByteBuffer bgraBuffer) {
Validate.isTrue(size.getWidth() * size.getHeight() * 4 == bgraBuffer.remaining(),
"BGRA buffer size is %d (cap: %d) but should be %d",
bgraBuffer.remaining(), bgraBuffer.capacity(), size.getWidth() * size.getHeight() * 4);
this.frameId = frameId;
this.size = size;
this.bgraBuffer = bgraBuffer;
}
public int getFrameId() {
return this.frameId;
}
public ReadableDimension getSize() {
return this.size;
}
public ByteBuffer getBgraBuffer() {
return this.bgraBuffer;
}
}

View File

@@ -405,15 +405,17 @@ public class GuiRenderSettings extends AbstractGuiPopup<GuiRenderSettings> {
} }
// Enable/Disable various options for blend export // Enable/Disable various options for blend export
boolean isEXR = encodingPresetDropdown.getSelectedValue() == RenderSettings.EncodingPreset.EXR;
boolean isBlend = renderMethod == RenderSettings.RenderMethod.BLEND; boolean isBlend = renderMethod == RenderSettings.RenderMethod.BLEND;
boolean isFFmpeg = !isBlend && !isEXR;
if (isBlend) { if (isBlend) {
videoWidth.setDisabled(); videoWidth.setDisabled();
videoHeight.setDisabled(); videoHeight.setDisabled();
} }
encodingPresetDropdown.setEnabled(!isBlend); encodingPresetDropdown.setEnabled(!isBlend);
exportCommand.setEnabled(!isBlend); exportCommand.setEnabled(isFFmpeg);
exportArguments.setEnabled(!isBlend); exportArguments.setEnabled(isFFmpeg);
antiAliasingDropdown.setEnabled(!isBlend); antiAliasingDropdown.setEnabled(isFFmpeg);
// Enable/Disable export args reset button // Enable/Disable export args reset button
boolean commandChanged = !exportCommand.getText().isEmpty(); boolean commandChanged = !exportCommand.getText().isEmpty();
@@ -519,7 +521,8 @@ public class GuiRenderSettings extends AbstractGuiPopup<GuiRenderSettings> {
userDefinedOutputFileName = false; userDefinedOutputFileName = false;
} else if (savedOutputFile.exists()) { } else if (savedOutputFile.exists()) {
String name = generateOutputFile(encodingPreset).getName(); String name = generateOutputFile(encodingPreset).getName();
this.outputFile = new File(savedOutputFile.isDirectory() ? savedOutputFile : savedOutputFile.getParentFile(), name); boolean isFolder = savedOutputFile.isDirectory() && !savedOutputFile.getName().endsWith(".exr");
this.outputFile = new File(isFolder ? savedOutputFile : savedOutputFile.getParentFile(), name);
userDefinedOutputFileName = false; userDefinedOutputFileName = false;
} else { } else {
this.outputFile = conformExtension(savedOutputFile, encodingPreset); this.outputFile = conformExtension(savedOutputFile, encodingPreset);
@@ -569,7 +572,7 @@ public class GuiRenderSettings extends AbstractGuiPopup<GuiRenderSettings> {
chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null, chromaKeyingCheckbox.isChecked() ? chromaKeyingColor.getColor() : null,
sphericalFov, Math.min(180, sphericalFov), sphericalFov, Math.min(180, sphericalFov),
injectSphericalMetadata.isChecked() && (serialize || injectSphericalMetadata.isEnabled()), injectSphericalMetadata.isChecked() && (serialize || injectSphericalMetadata.isEnabled()),
antiAliasingDropdown.getSelectedValue(), serialize || antiAliasingDropdown.isEnabled() ? antiAliasingDropdown.getSelectedValue() : RenderSettings.AntiAliasing.NONE,
exportCommand.getText(), exportCommand.getText(),
exportArguments.getText(), exportArguments.getText(),
net.minecraft.client.gui.screen.Screen.hasControlDown() net.minecraft.client.gui.screen.Screen.hasControlDown()

View File

@@ -265,9 +265,8 @@ public class GuiVideoRenderer extends GuiScreen implements Tickable {
guiRenderer.drawTexturedRect(x, y, 0, 0, width, height, videoWidth, videoHeight, videoWidth, videoHeight); guiRenderer.drawTexturedRect(x, y, 0, 0, width, height, videoWidth, videoHeight, videoWidth, videoHeight);
} }
public void updatePreview(BitmapFrame frame) { public void updatePreview(ByteBuffer buffer, ReadableDimension size) {
if (previewCheckbox.isChecked() && previewTexture != null) { if (previewCheckbox.isChecked() && previewTexture != null) {
ByteBuffer buffer = frame.getByteBuffer();
buffer.mark(); buffer.mark();
synchronized (this) { synchronized (this) {
//#if MC>=11400 //#if MC>=11400
@@ -279,7 +278,6 @@ public class GuiVideoRenderer extends GuiScreen implements Tickable {
// Note: Optifine changes the texture data array to be three times as long (for use by shaders), // Note: Optifine changes the texture data array to be three times as long (for use by shaders),
// we only want to initialize the first third and since we use our frame size, not the array size, // we only want to initialize the first third and since we use our frame size, not the array size,
// we're good to go. // we're good to go.
ReadableDimension size = frame.getSize();
int width = size.getWidth(); int width = size.getWidth();
for (int y = 0; y < size.getHeight(); y++) { for (int y = 0; y < size.getHeight(); y++) {
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {

View File

@@ -12,6 +12,7 @@ import com.replaymod.render.capturer.StereoscopicOpenGlFrameCapturer;
import com.replaymod.render.capturer.StereoscopicPboOpenGlFrameCapturer; import com.replaymod.render.capturer.StereoscopicPboOpenGlFrameCapturer;
import com.replaymod.render.capturer.WorldRenderer; import com.replaymod.render.capturer.WorldRenderer;
import com.replaymod.render.frame.CubicOpenGlFrame; import com.replaymod.render.frame.CubicOpenGlFrame;
import com.replaymod.render.frame.EXRFrame;
import com.replaymod.render.frame.ODSOpenGlFrame; import com.replaymod.render.frame.ODSOpenGlFrame;
import com.replaymod.render.frame.OpenGlFrame; import com.replaymod.render.frame.OpenGlFrame;
import com.replaymod.render.frame.BitmapFrame; import com.replaymod.render.frame.BitmapFrame;
@@ -25,7 +26,23 @@ import com.replaymod.render.processor.OpenGlToBitmapProcessor;
import com.replaymod.render.processor.StereoscopicToBitmapProcessor; import com.replaymod.render.processor.StereoscopicToBitmapProcessor;
import com.replaymod.render.utils.PixelBufferObject; import com.replaymod.render.utils.PixelBufferObject;
import java.io.IOException;
public class Pipelines { public class Pipelines {
public static Pipeline newEXRPipeline(RenderSettings.RenderMethod method, RenderInfo renderInfo, FrameConsumer<EXRFrame> consumer) {
return newPipeline(method, renderInfo, new FrameConsumer<BitmapFrame>() {
@Override
public void consume(BitmapFrame frame) {
consumer.consume(new EXRFrame(frame.getFrameId(), frame.getSize(), frame.getByteBuffer()));
}
@Override
public void close() throws IOException {
consumer.close();
}
});
}
public static Pipeline newPipeline(RenderSettings.RenderMethod method, RenderInfo renderInfo, FrameConsumer<BitmapFrame> consumer) { public static Pipeline newPipeline(RenderSettings.RenderMethod method, RenderInfo renderInfo, FrameConsumer<BitmapFrame> consumer) {
switch (method) { switch (method) {
case DEFAULT: case DEFAULT:

View File

@@ -43,6 +43,8 @@ import org.lwjgl.opengl.GL11;
//#endif //#endif
//#if MC>=11400 //#if MC>=11400
import com.replaymod.render.EXRWriter;
import com.replaymod.render.frame.EXRFrame;
import com.replaymod.render.mixin.MainWindowAccessor; import com.replaymod.render.mixin.MainWindowAccessor;
import net.minecraft.client.gui.screen.Screen; import net.minecraft.client.gui.screen.Screen;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
@@ -120,12 +122,23 @@ public class VideoRenderer implements RenderInfo {
this.renderingPipeline = Pipelines.newBlendPipeline(this); this.renderingPipeline = Pipelines.newBlendPipeline(this);
this.ffmpegWriter = null; this.ffmpegWriter = null;
//#if MC>=11400
} else if (settings.getEncodingPreset() == RenderSettings.EncodingPreset.EXR) {
this.renderingPipeline = Pipelines.newEXRPipeline(settings.getRenderMethod(), this, new EXRWriter(settings.getOutputFile().toPath()) {
@Override
public void consume(EXRFrame frame) {
gui.updatePreview(frame.getBgraBuffer(), frame.getSize());
super.consume(frame);
}
});
this.ffmpegWriter = null;
//#endif
} else { } else {
this.renderingPipeline = Pipelines.newPipeline(settings.getRenderMethod(), this, this.renderingPipeline = Pipelines.newPipeline(settings.getRenderMethod(), this,
ffmpegWriter = new FFmpegWriter(this) { ffmpegWriter = new FFmpegWriter(this) {
@Override @Override
public void consume(BitmapFrame frame) { public void consume(BitmapFrame frame) {
gui.updatePreview(frame); gui.updatePreview(frame.getByteBuffer(), frame.getSize());
super.consume(frame); super.consume(frame);
} }
}); });

View File

@@ -287,6 +287,14 @@ dependencies {
shadow 'com.google.api-client:google-api-client-java6:1.20.0', shadeExclusions shadow 'com.google.api-client:google-api-client-java6:1.20.0', shadeExclusions
shadow 'com.google.oauth-client:google-oauth-client-jetty:1.20.0' shadow 'com.google.oauth-client:google-oauth-client-jetty:1.20.0'
if (mcVersion >= 11400) { // need lwjgl 3
for (suffix in ['', ':natives-linux', ':natives-windows', ':natives-macos']) {
shadow('org.lwjgl:lwjgl-tinyexr:3.2.2' + suffix) {
exclude group: 'org.lwjgl', module: 'lwjgl' // comes with MC
}
}
}
if (FABRIC) { if (FABRIC) {
shadow 'org.apache.maven:maven-artifact:3.6.1' shadow 'org.apache.maven:maven-artifact:3.6.1'
} }
@@ -381,6 +389,8 @@ task configureRelocation() {
} else if (!pkg.startsWith('org/apache/logging')) { } else if (!pkg.startsWith('org/apache/logging')) {
pkgs << pkg.substring(0, pkg.indexOf('/', 'org/apache/'.length())) pkgs << pkg.substring(0, pkg.indexOf('/', 'org/apache/'.length()))
} }
} else if (pkg.startsWith('org/lwjgl')) {
return // either bundled with MC or uses natives which we can't relocate
} else if (!pkg.startsWith('org/spongepowered')) { } else if (!pkg.startsWith('org/spongepowered')) {
pkgs << pkg.substring(0, pkg.indexOf('/', 4)) pkgs << pkg.substring(0, pkg.indexOf('/', 4))
} }