mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-05-23 14:26:32 -04:00
This commit imports the C# engine code and game files, excluding C++ source code. [Source-Commit: ceb3d758046e50faa6258bc3b658a30c97743268]
373 lines
11 KiB
C#
373 lines
11 KiB
C#
using Sandbox.MovieMaker;
|
|
using Sandbox.Rendering;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Editor.MovieMaker;
|
|
|
|
#nullable enable
|
|
|
|
/// <summary>
|
|
/// Controls how many sub-frames are rendered for every final exported frame. Higher will make motion
|
|
/// smoother, but at the expense of taking longer to render. Values are in Hz.
|
|
/// </summary>
|
|
public enum MotionQuality
|
|
{
|
|
Low = 4,
|
|
Medium = 16,
|
|
High = 64
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes how long the sensor is exposed for each output frame. Between 0 and 360, with 360
|
|
/// being exposed for the entire frame.
|
|
/// </summary>
|
|
public enum Exposure
|
|
{
|
|
Instant = 0,
|
|
VeryFast = 30,
|
|
Fast = 90,
|
|
Medium = 180,
|
|
Slow = 270,
|
|
VerySlow = 360
|
|
}
|
|
|
|
public enum ExportMode
|
|
{
|
|
/// <summary>
|
|
/// Write a single .mp4 or .webm video file.
|
|
/// </summary>
|
|
[Icon( "video_file" )]
|
|
VideoFile,
|
|
|
|
/// <summary>
|
|
/// Write separate .png or .jpg image files for each frame.
|
|
/// </summary>
|
|
[Icon( "auto_awesome_motion" )]
|
|
ImageSequence,
|
|
|
|
/// <summary>
|
|
/// Write a single .png or .jpg image file containing a grid of frames.
|
|
/// </summary>
|
|
[Icon( "grid_view" )]
|
|
ImageAtlas
|
|
}
|
|
|
|
public sealed class VideoExportConfig
|
|
{
|
|
/// <summary>
|
|
/// Output frame rate of the final video.
|
|
/// </summary>
|
|
[Feature( "Dimensions", Icon = "aspect_ratio" )]
|
|
public int FrameRate { get; set; } = 60;
|
|
|
|
/// <summary>
|
|
/// Output resolution of the final video.
|
|
/// </summary>
|
|
[Feature( "Dimensions", Icon = "aspect_ratio" )]
|
|
public Vector2Int Resolution { get; set; } = new( 1920, 1080 );
|
|
|
|
/// <summary>
|
|
/// How many frames to render and discard before exporting, to warm up any temporal ray traced elements.
|
|
/// </summary>
|
|
[JsonIgnore, Hide]
|
|
internal int WarmupFrameCount { get; set; } = 256;
|
|
|
|
[Feature( "Encoding", Icon = "terminal" )]
|
|
public ExportMode Mode { get; set; }
|
|
|
|
[Feature( "Encoding", Icon = "terminal" ), JsonIgnore, ShowIf( nameof( Mode ), ExportMode.VideoFile )]
|
|
public bool UseRecommendedBitrate
|
|
{
|
|
get => CustomBitrate <= 0;
|
|
set => CustomBitrate = value ? 0 : RecommendedBitrate;
|
|
}
|
|
|
|
private bool ShowCustomBitrate => Mode == ExportMode.VideoFile && UseRecommendedBitrate;
|
|
private bool ShowRecommendedBitrate => Mode == ExportMode.VideoFile && !UseRecommendedBitrate;
|
|
|
|
/// <summary>
|
|
/// How many Mbit/s to attempt to export at. If this value is too low, some frames may get skipped for some reason.
|
|
/// </summary>
|
|
[Feature( "Encoding", Icon = "terminal" ), Title( "Bitrate (Mbit/s)" )]
|
|
[ShowIf( nameof( ShowCustomBitrate ), true )]
|
|
public int CustomBitrate { get; set; }
|
|
|
|
[JsonIgnore]
|
|
public int Bitrate => UseRecommendedBitrate ? RecommendedBitrate : CustomBitrate;
|
|
|
|
private const float RecommendedBitsPerPixel = 0.2f;
|
|
|
|
/// <inheritdoc cref="CustomBitrate"/>
|
|
[Feature( "Encoding", Icon = "terminal" ), Title( "Bitrate (Mbit/s)" ), JsonIgnore]
|
|
[ShowIf( nameof( ShowRecommendedBitrate ), true )]
|
|
public int RecommendedBitrate => (int)MathF.Ceiling( Resolution.x * Resolution.y * FrameRate * RecommendedBitsPerPixel / 1_000_000 );
|
|
|
|
[Feature( "Encoding", Icon = "terminal" ), ShowIf( nameof( Mode ), ExportMode.VideoFile )]
|
|
public VideoWriter.Codec Codec { get; set; } = VideoWriter.Codec.H264;
|
|
|
|
/// <summary>
|
|
/// Describes how long the sensor is exposed for each output frame.
|
|
/// </summary>
|
|
[Feature( "Motion", Icon = "animation" )]
|
|
public Exposure Exposure { get; set; } = Exposure.Medium;
|
|
|
|
/// <summary>
|
|
/// Controls how many sub-frames are rendered for every final exported frame. Higher will make motion
|
|
/// smoother, but at the expense of taking longer to render.
|
|
/// </summary>
|
|
[HideIf( nameof( Exposure ), Exposure.Instant )]
|
|
[Feature( "Motion", Icon = "animation" )]
|
|
public MotionQuality MotionQuality { get; set; } = MotionQuality.Medium;
|
|
|
|
[JsonIgnore, Hide]
|
|
public int SubFramesPerFrame => Exposure == Exposure.Instant ? 1 : Math.Max( 1, (int)MotionQuality );
|
|
|
|
public VideoWriter.Config GetVideoWriterConfig( string filePath ) => new()
|
|
{
|
|
FrameRate = FrameRate,
|
|
Width = Resolution.x,
|
|
Height = Resolution.y,
|
|
Bitrate = UseRecommendedBitrate ? RecommendedBitrate : CustomBitrate,
|
|
Codec = Codec,
|
|
Container = GetContainerForExtension( Path.GetExtension( filePath ) )
|
|
};
|
|
|
|
private static VideoWriter.Container GetContainerForExtension( string extension )
|
|
{
|
|
return extension.ToLowerInvariant() switch
|
|
{
|
|
".mp4" => VideoWriter.Container.MP4,
|
|
".webm" => VideoWriter.Container.WebM,
|
|
".webp" => VideoWriter.Container.WebP,
|
|
_ => VideoWriter.Container.MP4
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders frames from a <see cref="Session"/> to a <see cref="Pixmap"/> / <see cref="Bitmap"/> / <see cref="Texture"/>.
|
|
/// </summary>
|
|
public sealed class SessionRenderer
|
|
{
|
|
private readonly Session _session;
|
|
private Task? _renderTask;
|
|
|
|
public bool IsRendering => _renderTask is { IsCompleted: false };
|
|
|
|
public SessionRenderer( Session session )
|
|
{
|
|
_session = session;
|
|
}
|
|
|
|
public delegate Task FrameCallback( MovieTime time, byte[] pixels, CancellationToken ct );
|
|
|
|
public async Task RenderAsync( MovieTimeRange timeRange, VideoExportConfig config, FrameCallback onFrame,
|
|
CancellationToken ct = default )
|
|
{
|
|
lock ( this )
|
|
{
|
|
_renderTask = RenderCoreAsync( timeRange, config, onFrame, ct );
|
|
}
|
|
|
|
try
|
|
{
|
|
await _renderTask;
|
|
}
|
|
finally
|
|
{
|
|
lock ( this )
|
|
{
|
|
_renderTask = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task RenderCoreAsync( MovieTimeRange timeRange, VideoExportConfig config, FrameCallback onFrame, CancellationToken ct )
|
|
{
|
|
if ( _renderTask is { } prevTask )
|
|
{
|
|
try
|
|
{
|
|
await prevTask;
|
|
}
|
|
catch ( TaskCanceledException )
|
|
{
|
|
//
|
|
}
|
|
}
|
|
|
|
var frameCount = timeRange.Duration.GetFrameCount( config.FrameRate );
|
|
|
|
using var captureCamera = new SceneCamera( "Video Export Camera" );
|
|
|
|
using var subFrameTex = Texture.CreateRenderTarget( "VideoExportSubFrame", ImageFormat.RGBA16161616, config.Resolution );
|
|
using var accumulatedTex = Texture.Create( config.Resolution.x, config.Resolution.y, ImageFormat.RGBA32323232F )
|
|
.WithName( "VideoExportAccumulated" )
|
|
.WithUAVBinding()
|
|
.WithGPUOnlyUsage()
|
|
.Finish();
|
|
|
|
var framePixels = new byte[config.Resolution.x * config.Resolution.y * 4];
|
|
|
|
var subFrameCount = config.SubFramesPerFrame;
|
|
var exposureFraction = (int)config.Exposure / 360f;
|
|
var exposureStart = 0.5f - exposureFraction * 0.5f;
|
|
var exposureEnd = 0.5f + exposureFraction * 0.5f;
|
|
|
|
var accumulate = new ComputeShader( "moviemaker_accumulate_cs" );
|
|
|
|
accumulate.Attributes.Set( "Subframe", subFrameTex );
|
|
accumulate.Attributes.Set( "Accumulated", accumulatedTex );
|
|
accumulate.Attributes.Set( "InvFrames", 1f / subFrameCount );
|
|
|
|
var alphaDivide = new ComputeShader( "moviemaker_alphadivide_cs" );
|
|
|
|
alphaDivide.Attributes.Set( "Accumulated", accumulatedTex );
|
|
|
|
_session.PlayheadTime = timeRange.Start;
|
|
|
|
var prevTime = timeRange.Start;
|
|
|
|
Task? prevFrameTask = null;
|
|
|
|
try
|
|
{
|
|
for ( var i = -1; i < frameCount; i++ )
|
|
{
|
|
if ( ct.IsCancellationRequested ) return;
|
|
|
|
var isWarmup = i < 0;
|
|
|
|
if ( !isWarmup && subFrameCount > 1 )
|
|
{
|
|
accumulatedTex.Clear( new Color( 0f, 0f, 0f, 0f ) );
|
|
}
|
|
|
|
var frameTime = timeRange.Start + (isWarmup ? 0 : MovieTime.FromFrames( i, config.FrameRate ));
|
|
|
|
for ( var j = 0; j < (isWarmup ? config.WarmupFrameCount : subFrameCount); ++j )
|
|
{
|
|
var subFrameFraction = isWarmup ? 0f : (float)j / subFrameCount;
|
|
var subFrameTime = MathX.Lerp( exposureStart, exposureEnd, subFrameFraction ) / config.FrameRate;
|
|
var nextTime = frameTime + MovieTime.FromSeconds( subFrameTime );
|
|
|
|
_session.PlayheadTime = nextTime;
|
|
_session.ScrollToPlayheadTime();
|
|
|
|
BeforeRenderFrame( captureCamera, config, nextTime - prevTime );
|
|
|
|
// Render a (sub)frame!
|
|
|
|
RenderToTextureMethod.Invoke( captureCamera, [subFrameTex, (Vector2?)null, default( ViewSetup )] );
|
|
|
|
if ( !isWarmup && subFrameCount > 1 )
|
|
{
|
|
accumulate.Dispatch( subFrameTex.Width, subFrameTex.Height, 1 );
|
|
}
|
|
|
|
// Yield to let the scene viewport render when it wants to so we get a preview,
|
|
// and also so temporary resources get cleaned up periodically
|
|
|
|
await Task.Yield();
|
|
|
|
prevTime = nextTime;
|
|
}
|
|
|
|
if ( isWarmup ) continue;
|
|
|
|
// Need to divide by alpha to convert from premultiplied
|
|
|
|
if ( subFrameCount > 1 )
|
|
{
|
|
alphaDivide.Dispatch( subFrameTex.Width, subFrameTex.Height, 1 );
|
|
}
|
|
|
|
// Have to wait for prev frame to finish writing, since it'll be using framePixels
|
|
|
|
if ( prevFrameTask is not null )
|
|
{
|
|
await prevFrameTask;
|
|
}
|
|
|
|
// Grab the frame from the GPU and add it to the video writer
|
|
|
|
var frameSourceTex = subFrameCount > 1 ? accumulatedTex : subFrameTex;
|
|
|
|
frameSourceTex.GetPixels( (0, 0, frameSourceTex.Width, frameSourceTex.Height), 0, 0,
|
|
MemoryMarshal.Cast<byte, Color32>( framePixels.AsSpan() ),
|
|
ImageFormat.RGBA8888, (frameSourceTex.Width, frameSourceTex.Height) );
|
|
|
|
prevFrameTask = onFrame.Invoke( frameTime, framePixels, ct );
|
|
}
|
|
|
|
if ( prevFrameTask is not null )
|
|
{
|
|
await prevFrameTask;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
foreach ( var particleEffect in _session.Player.Scene.GetAll<ParticleEffect>() )
|
|
{
|
|
particleEffect.Paused = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void BeforeRenderFrame( SceneCamera captureCamera, VideoExportConfig config, MovieTime deltaTime )
|
|
{
|
|
var camera = _session.Player.Scene.Camera;
|
|
|
|
// Update camera
|
|
|
|
var aspect = (float)config.Resolution.x / config.Resolution.y;
|
|
|
|
camera.UpdateSceneCamera( captureCamera );
|
|
|
|
captureCamera.Size = config.Resolution.x;
|
|
captureCamera.FieldOfView = camera.FovAxis == CameraComponent.Axis.Vertical
|
|
? Screen.CreateVerticalFieldOfView( camera.FieldOfView, aspect )
|
|
: camera.FieldOfView;
|
|
|
|
// Make sure SkinnedModelRenderers have updated
|
|
|
|
SignalMethod.Invoke( _session.Player.Scene, [GameObjectSystem.Stage.UpdateBones] );
|
|
|
|
// Step particle effects
|
|
|
|
foreach ( var particleEffect in _session.Player.Scene.GetAll<ParticleEffect>() )
|
|
{
|
|
try
|
|
{
|
|
particleEffect.Paused = false;
|
|
particleEffect.Step( (float)deltaTime.TotalSeconds );
|
|
particleEffect.Paused = true;
|
|
}
|
|
catch ( Exception ex )
|
|
{
|
|
Log.Warning( ex );
|
|
}
|
|
}
|
|
|
|
// Make sure ParticleGameSystem moves particle sprites to their new positions
|
|
|
|
SignalMethod.Invoke( _session.Player.Scene, [GameObjectSystem.Stage.FinishUpdate] );
|
|
}
|
|
|
|
private static MethodInfo RenderToTextureMethod { get; } = typeof( SceneCamera ).GetMethod( "RenderToTexture",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic )!;
|
|
|
|
private static MethodInfo SignalMethod { get; } = typeof( Scene ).GetMethod( "Signal",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic )!;
|
|
}
|
|
|
|
partial class Session
|
|
{
|
|
public SessionRenderer Renderer { get; }
|
|
}
|