Rework rendering pipeline to make better use of multithreading
Move OpenGL frame to ARGB conversion to processing threads Move video exporting to processing threads Skip creation of BufferedImage and instead use ByteBuffer with ARGB content directly
This commit is contained in:
@@ -1,63 +1,44 @@
|
||||
package eu.crushedpixel.replaymod.video;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
import eu.crushedpixel.replaymod.settings.RenderOptions;
|
||||
import eu.crushedpixel.replaymod.utils.ByteBufferPool;
|
||||
import eu.crushedpixel.replaymod.utils.StreamPipe;
|
||||
import eu.crushedpixel.replaymod.utils.StringUtils;
|
||||
import eu.crushedpixel.replaymod.video.frame.ARGBFrame;
|
||||
import eu.crushedpixel.replaymod.video.rendering.FrameConsumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.crash.CrashReport;
|
||||
import net.minecraft.crash.CrashReportCategory;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.lwjgl.util.ReadableDimension;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBuffer;
|
||||
import java.awt.image.DataBufferByte;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.apache.commons.lang3.Validate.isTrue;
|
||||
|
||||
public class VideoWriter {
|
||||
public class VideoWriter implements FrameConsumer<ARGBFrame> {
|
||||
|
||||
private final RenderOptions options;
|
||||
private final Process process;
|
||||
private final OutputStream outputStream;
|
||||
private final WritableByteChannel channel;
|
||||
private final String commandArgs;
|
||||
|
||||
private volatile boolean active = true;
|
||||
private volatile boolean cancelled = false;
|
||||
|
||||
private int queueLimit;
|
||||
private final Queue<BufferedImage> 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(final VideoRenderer renderer, final RenderOptions options) throws IOException {
|
||||
public VideoWriter(final RenderOptions options) throws IOException {
|
||||
this.options = options.copy();
|
||||
this.queueLimit = options.getWriterQueueSize();
|
||||
this.toWrite = new LinkedList<BufferedImage>();
|
||||
|
||||
File outputFolder = options.getOutputFile().getParentFile();
|
||||
String fileName = options.getOutputFile().getName();
|
||||
|
||||
final String args = options.getExportCommandArgs()
|
||||
commandArgs = options.getExportCommandArgs()
|
||||
.replace("%WIDTH%", String.valueOf(options.getWidth()))
|
||||
.replace("%HEIGHT%", String.valueOf(options.getHeight()))
|
||||
.replace("%FPS%", String.valueOf(options.getFps()))
|
||||
@@ -66,127 +47,63 @@ public class VideoWriter {
|
||||
|
||||
List<String> command = new ArrayList<String>();
|
||||
command.add(options.getExportCommand());
|
||||
command.addAll(StringUtils.translateCommandline(args));
|
||||
System.out.println("Starting " + options.getExportCommand() + " with args: " + args);
|
||||
command.addAll(StringUtils.translateCommandline(commandArgs));
|
||||
System.out.println("Starting " + options.getExportCommand() + " with args: " + commandArgs);
|
||||
process = new ProcessBuilder(command).directory(outputFolder).start();
|
||||
OutputStream exportLogOut = new FileOutputStream("export.log");
|
||||
new StreamPipe(process.getInputStream(), exportLogOut).start();
|
||||
new StreamPipe(process.getErrorStream(), exportLogOut).start();
|
||||
outputStream = process.getOutputStream();
|
||||
channel = Channels.newChannel(outputStream);
|
||||
}
|
||||
|
||||
writerThread = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
IOUtils.closeQuietly(outputStream);
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
while (!cancelled && (active || !toWrite.isEmpty())) {
|
||||
try {
|
||||
lock.lockInterruptibly();
|
||||
BufferedImage img;
|
||||
try {
|
||||
img = toWrite.poll();
|
||||
if (img == null) {
|
||||
noLongerEmptyCondition.await();
|
||||
img = toWrite.poll();
|
||||
}
|
||||
noLongerFullCondition.signal();
|
||||
if (toWrite.isEmpty()) {
|
||||
emptyCondition.signalAll();
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
DataBuffer imgBuffer = img.getRaster().getDataBuffer();
|
||||
if (imgBuffer instanceof DataBufferByte) {
|
||||
outputStream.write(((DataBufferByte) imgBuffer).getData());
|
||||
} else if (imgBuffer instanceof DataBufferInt) {
|
||||
ByteBuffer byteBuffer = ByteBuffer.allocate(img.getWidth() * img.getHeight() * 4);
|
||||
byteBuffer.asIntBuffer().put(((DataBufferInt) imgBuffer).getData());
|
||||
channel.write(byteBuffer);
|
||||
} else {
|
||||
throw new RuntimeException("DataBuffer type not supported: " + imgBuffer.getClass());
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
long startTime = System.nanoTime();
|
||||
long rem = TimeUnit.SECONDS.toNanos(30);
|
||||
do {
|
||||
try {
|
||||
process.exitValue();
|
||||
break;
|
||||
} catch(IllegalThreadStateException ex) {
|
||||
if (rem > 0) {
|
||||
try {
|
||||
Thread.sleep(Math.min(TimeUnit.NANOSECONDS.toMillis(rem) + 1, 100));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
toWrite.clear();
|
||||
IOUtils.closeQuietly(outputStream);
|
||||
} catch (IOException e) {
|
||||
if (active) {
|
||||
CrashReport report = new CrashReport("Exporting frame", e);
|
||||
CrashReportCategory exportDetails = report.makeCategory("Export details");
|
||||
exportDetails.addCrashSection("Export command", options.getExportCommand());
|
||||
exportDetails.addCrashSection("Export args", args);
|
||||
Minecraft.getMinecraft().crashed(report);
|
||||
renderer.cancel();
|
||||
}
|
||||
} finally {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
}, "replaymod-video-writer");
|
||||
writerThread.start();
|
||||
rem = TimeUnit.SECONDS.toNanos(30) - (System.nanoTime() - startTime);
|
||||
} while (rem > 0);
|
||||
|
||||
process.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.");
|
||||
isTrue(image.getWidth() == options.getWidth(), "Width has to be " + options.getWidth() + " but was " + image.getWidth());
|
||||
isTrue(image.getHeight() == options.getHeight(), "Height has to be " + options.getHeight() + " but was " + image.getHeight());
|
||||
|
||||
lock.lock();
|
||||
@Override
|
||||
public void consume(ARGBFrame frame) {
|
||||
try {
|
||||
while (toWrite.size() >= queueLimit) {
|
||||
if (waitIfFull) {
|
||||
noLongerFullCondition.await();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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();
|
||||
checkSize(frame.getSize());
|
||||
channel.write(frame.getByteBuffer());
|
||||
ByteBufferPool.release(frame.getByteBuffer());
|
||||
} catch (Throwable t) {
|
||||
CrashReport report = CrashReport.makeCrashReport(t, "Exporting frame");
|
||||
CrashReportCategory exportDetails = report.makeCategory("Export details");
|
||||
exportDetails.addCrashSection("Export command", options.getExportCommand());
|
||||
exportDetails.addCrashSection("Export args", commandArgs);
|
||||
Minecraft.getMinecraft().crashed(report);
|
||||
}
|
||||
}
|
||||
|
||||
public void waitForFinish() throws InterruptedException {
|
||||
Preconditions.checkState(!active, "Video writer still active.");
|
||||
writerThread.join();
|
||||
private void checkSize(ReadableDimension size) {
|
||||
checkSize(size.getWidth(), size.getHeight());
|
||||
}
|
||||
|
||||
private void checkSize(int width, int height) {
|
||||
isTrue(width == options.getWidth(), "Width has to be %d but was %d", options.getWidth(), width);
|
||||
isTrue(height == options.getHeight(), "Height has to be %d but was %d", options.getHeight(), height);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user