Editor-side baker that renders a model from a grid of view directions into flatpack impostor atlases (albedo, normal, depth, roughness/AO), writes the PNG/PFM textures and a generated .vmat, and creates or updates the .fpack resource. It also fits soft coverage, runs a quality check, and auto-selects a quality level by climbing Low to Ultra until the bake holds up.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;
namespace Flatpack.EditorTools;
/// <summary>
/// Bakes a model into a Flatpack impostor: albedo, normal, depth and roughness + AO atlases, a frame lookup texture, a
/// material using <c>flatpack_impostor.shader</c>, and a <see cref="FlatpackImpostor"/> resource that ties them together.
/// </summary>
/// <remarks>
/// <para>
/// The model is rendered alone in a throwaway scene with an orthographic camera, once per frame of the
/// N x N grid. Each camera is fitted to that view's outline and depth range (stored in the frame lookup texture), so the
/// object fills its frame. Each pass uses one of the engine's camera debug views, which write material data straight to
/// the target with no lighting, exposure or tonemapping:
/// </para>
/// <list type="bullet">
/// <item>Albedo: the colour texture as sRGB bytes, alpha = coverage.</item>
/// <item>NormalMap: the normal-mapped world normal packed as n * 0.5 + 0.5. The model sits at the origin with no
/// rotation, so world space is object space.</item>
/// <item>Roughness, AmbientOcclusion: linear values as bytes.</item>
/// <item>Depth: there is no depth view, so <c>flatpack_bake_depth.shader</c> goes on as a material override and
/// writes linear depth as albedo (16 bits split over red and green), read back through the Albedo view.</item>
/// </list>
/// </remarks>
public static class FlatpackBaker
{
/// <summary>
/// The runtime impostor shader the generated material uses.
/// </summary>
public const string ImpostorShader = "shaders/flatpack_impostor.shader";
const string DepthShader = "shaders/flatpack_bake_depth.shader";
/// <summary>Opacity at which a supersample counts as covered: the usual alpha-test cut of s&box materials.</summary>
const byte AlphaTestCut = 128;
/// <summary>
/// Bakes whose covered texels are more than this share partly covered are see-through detail. Measured on the test
/// set: fence 0.75, weed tree 0.50, everything solid or leafy 0.06 to 0.24.
/// </summary>
public const float SeeThroughShare = 0.4f;
/// <summary>
/// Framed bakes whose covered texels are more than this share partly covered are fine wire mesh and get baked again
/// unframed. Measured framed in iteration 8: fence 0.59, weed tree 0.42, solid and leafy models 0.10 or less.
/// </summary>
const float FineMeshShare = 0.5f;
/// <summary>
/// What a bake wrote.
/// </summary>
public sealed class Result
{
/// <summary>The <c>.fpack</c> resource, loaded.</summary>
public FlatpackImpostor Impostor { get; init; }
/// <summary>Asset-relative path of the <c>.fpack</c>.</summary>
public string ImpostorPath { get; init; }
/// <summary>Asset-relative path of the generated <c>.vmat</c>.</summary>
public string MaterialPath { get; init; }
/// <summary>Absolute paths of every file written.</summary>
public IReadOnlyList<string> Files { get; init; }
/// <summary>Wall-clock bake time.</summary>
public TimeSpan Duration { get; init; }
/// <summary>The quality level the bake ended up at (Auto picks one).</summary>
public FlatpackQuality Level { get; init; }
/// <summary>How closely the card matches the model by distance, when the bake was measured.</summary>
public FlatpackQualityCheck.Report Measured { get; init; }
}
/// <summary>
/// Bake one model at the settings' quality level, or for <see cref="FlatpackQuality.Auto"/> at the cheapest level that
/// matches the model from <see cref="FlatpackBakeSettings.AutoTargetRadii"/> outward. Measures the result when
/// <see cref="FlatpackBakeSettings.MeasureQuality"/> is on (always for Auto) and stores where it holds up.
/// </summary>
public static async Task<Result> BakeAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress = null, CancellationToken cancel = default )
{
if ( settings.Quality != FlatpackQuality.Auto )
{
var result = await BakeLevelAsync( model, settings, progress, cancel );
return settings.MeasureQuality ? await MeasureAsync( result, progress, cancel ) : result;
}
// Auto: climb the levels until one holds up at the target distance. Each bake overwrites the last.
var timer = Stopwatch.StartNew();
var levels = new[] { FlatpackQuality.Low, FlatpackQuality.Medium, FlatpackQuality.High, FlatpackQuality.Ultra };
var best = FlatpackQuality.Ultra;
var bestScore = float.MaxValue;
Result last = null;
for ( var i = 0; i < levels.Length; i++ )
{
var level = levels[i];
var step = i;
var baked = await BakeLevelAsync( model, settings.WithQuality( level ),
( f, text ) => progress?.Invoke( (step + f * 0.8f) / levels.Length, $"{level}: {text}" ), cancel );
last = await MeasureAsync( baked, ( f, text ) => progress?.Invoke( (step + 0.8f + f * 0.2f) / levels.Length, $"{level}: {text}" ), cancel );
// See-through detail goes straight to Ultra. At Medium a wire grid has about one texel per wire, so its frames
// line up badly and the panel doubles when seen at an angle, even where the bake check passes it (iteration 7).
if ( last.Impostor.SeeThrough > SeeThroughShare && level != FlatpackQuality.Ultra )
{
i = levels.Length - 2;
continue;
}
// Holds up from the target (judged out to 16 radii; past that every level matches as well as it ever will).
if ( last.Measured.HoldsUpFrom <= MathF.Min( settings.AutoTargetRadii, 16.0f ) )
return Finish( last, timer );
var score = last.Measured.HoldsUpFrom;
if ( score < bestScore )
{
bestScore = score;
best = level;
}
}
// Nothing held up (fine wire mesh, for example). Keep whichever level held up from closest.
if ( best != FlatpackQuality.Ultra )
{
var baked = await BakeLevelAsync( model, settings.WithQuality( best ), progress, cancel );
last = await MeasureAsync( baked, progress, cancel );
}
return Finish( last, timer );
}
static Result Finish( Result r, Stopwatch timer ) => new()
{
Impostor = r.Impostor, ImpostorPath = r.ImpostorPath, MaterialPath = r.MaterialPath, Files = r.Files,
Level = r.Level, Measured = r.Measured, Duration = timer.Elapsed,
};
/// <summary>
/// Measure a finished bake and store where it holds up on its <c>.fpack</c>.
/// </summary>
static async Task<Result> MeasureAsync( Result baked, Action<float, string> progress, CancellationToken cancel )
{
if ( baked.Impostor.SeeThrough > SeeThroughShare )
{
progress?.Invoke( 0.0f, "Fitting see-through coverage to the model" );
var (soft, growth) = await FlatpackQualityCheck.FitSoftCoverageAsync( baked.Impostor, cancel );
WriteMaterialValue( baked.MaterialPath, "g_flFlatpackSoftCoverage", soft );
WriteMaterialValue( baked.MaterialPath, "g_flFlatpackSoftGrowth", growth );
}
progress?.Invoke( 0.0f, "Measuring against the model" );
var report = await FlatpackQualityCheck.MeasureAsync( baked.Impostor, cancel: cancel );
var impostor = baked.Impostor;
impostor.HoldsUpFrom = float.IsPositiveInfinity( report.HoldsUpFrom ) ? -1.0f : report.HoldsUpFrom;
AssetSystem.FindByPath( baked.ImpostorPath )?.SaveToDisk( impostor );
progress?.Invoke( 1.0f, report.Summary );
return new Result
{
Impostor = impostor, ImpostorPath = baked.ImpostorPath, MaterialPath = baked.MaterialPath, Files = baked.Files,
Duration = baked.Duration, Level = baked.Level, Measured = report,
};
}
/// <summary>
/// Share of covered texels (alpha above 2 of 255) that are only partly covered (below 250).
/// </summary>
static float PartialCoverageShare( Color32[] albedo )
{
long covered = 0, partial = 0;
foreach ( var c in albedo )
{
if ( c.a <= 2 ) continue;
covered++;
if ( c.a < 250 ) partial++;
}
return covered > 0 ? partial / (float)covered : 0.0f;
}
/// <summary>
/// Set one value in a generated <c>.vmat</c> (adding the line if it's missing) and recompile it.
/// </summary>
static void WriteMaterialValue( string materialPath, string key, float value )
{
var asset = AssetSystem.FindByPath( materialPath );
if ( asset is null || !File.Exists( asset.AbsolutePath ) )
return;
var line = $"\t{key} \"{value.ToString( "0.000000", CultureInfo.InvariantCulture )}\"";
var lines = File.ReadAllLines( asset.AbsolutePath ).ToList();
var index = lines.FindIndex( l => l.TrimStart().StartsWith( key + " " ) );
if ( index >= 0 )
lines[index] = line;
else
lines.Insert( Math.Max( 0, lines.FindLastIndex( l => l.Trim() == "}" ) ), line );
File.WriteAllLines( asset.AbsolutePath, lines );
asset.Compile( true );
}
/// <summary>
/// Bake one model at one quality level. Runs on the main thread but yields between frames, so the editor keeps
/// drawing; cancel with <paramref name="cancel"/>. <paramref name="progress"/> gets a 0..1 fraction and a status line.
/// </summary>
static async Task<Result> BakeLevelAsync( Model model, FlatpackBakeSettings settings, Action<float, string> progress, CancellationToken cancel )
{
if ( model is null || model.IsError )
throw new ArgumentException( "Pick a valid model to bake." );
var timer = Stopwatch.StartNew();
var (grid, atlasSize, searchSteps) = settings.Resolve();
var n = Math.Clamp( grid, 2, 32 );
// The shader addresses frame (x, y) at (x + u) / n of the atlas, so the atlas must be exactly n frames wide.
// 2048 / 12 isn't whole; round the frame down (to keep the atlas a multiple of 4 for block compression) and
// shrink the atlas to fit, rather than let the frames drift out of place across the atlas.
var cell = (int)atlasSize / n;
while ( (cell * n) % 4 != 0 )
cell--;
var atlas = cell * n;
var layout = settings.Layout;
var bounds = model.RenderBounds;
if ( bounds.Size.Length < 0.001f )
bounds = model.Bounds;
// A sphere fitted to the vertices is usually much smaller than the box's half diagonal, so the object fills more
// of every frame. Fall back to the box for models without readable vertices.
var vertices = model.GetVertices();
var points = vertices is { Length: > 0 } ? vertices.Select( v => v.Position ).ToArray() : null;
var (center, radius) = points is not null
? FlatpackMath.BoundingSphere( points )
: FlatpackMath.BoundingSphere( bounds );
var (folder, assetsRoot) = ResolveOutputFolder( model, settings );
var name = SafeName( model );
var fitted = settings.FitEachView && points is not null;
var fits = FitFrames( points, center, radius, n, cell, layout, fitted );
var albedo = new Color32[atlas * atlas];
var normal = new Color32[atlas * atlas];
var depth = new float[atlas * atlas];
var rao = settings.BakeRoughnessAo ? new Color32[atlas * atlas] : null;
var supersample = Math.Clamp( settings.Supersample, 1, 4 );
progress?.Invoke( 0.0f, $"Baking {name}" );
var bakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );
var seeThrough = PartialCoverageShare( albedo );
// Fine wire mesh does worse framed: a fence panel seen edge-on squeezes into a sliver of texels, and its card came
// out sparser than the real fence from 16 radii (iteration 8: 64 to 68% wrong pixels against 45 unframed). Bake
// those views sphere-sized again. The weed tree's twigs (0.42 framed) keep the framing, which helped them a lot.
if ( fitted && seeThrough > FineMeshShare )
{
progress?.Invoke( 0.0f, $"Baking {name} again, unframed (fine mesh)" );
fits = FitFrames( points, center, radius, n, cell, layout, false );
bakedMetal = await CaptureFramesAsync( model, layout, n, cell, atlas, center, radius, fits, albedo, normal, depth, rao, settings.BakeSelfShadow, supersample, progress, cancel );
seeThrough = PartialCoverageShare( albedo );
}
cancel.ThrowIfCancellationRequested();
progress?.Invoke( 0.85f, "Padding edges" );
await Task.Delay( 1 );
DilateIntoFrames( albedo, normal, depth, rao, atlas, cell, settings.EdgePadding );
var mipAlphaScales = MipAlphaScales( albedo, atlas );
cancel.ThrowIfCancellationRequested();
progress?.Invoke( 0.9f, "Writing files" );
await Task.Delay( 1 );
Directory.CreateDirectory( folder );
var files = new List<string>();
var depthBits = settings.DepthBits >= 12 ? 16 : 8;
var albedoFile = WritePng( Path.Combine( folder, $"{name}_fp_albedo.png" ), albedo, atlas, files );
var normalFile = WritePng( Path.Combine( folder, $"{name}_fp_normal.png" ), normal, atlas, files );
var depthFile = depthBits == 16
? WritePfm( Path.Combine( folder, $"{name}_fp_depth.pfm" ), depth, atlas, files )
: WritePng( Path.Combine( folder, $"{name}_fp_depth.png" ), DepthBytes( depth ), atlas, files );
var framesFile = WriteFrames( Path.Combine( folder, $"{name}_fp_frames.png" ), fits, n, files );
// The coarse map must never read deeper than the depth texture it summarises, or the search would skip a block
// it should have searched: BC4 rounds by a few steps, R16 by half of one.
var coarse = CoarseDepth( depth, atlas, cell, n, depthBits == 16 ? 1.0f / 65535.0f : 4.0f / 255.0f, out var coarseSize );
var coarseFile = Path.Combine( folder, $"{name}_fp_depthmin.pfm" );
FlatpackPfm.WriteGray( coarseFile, coarse, coarseSize, coarseSize );
files.Add( coarseFile );
var raoFile = rao is null ? null : WritePng( Path.Combine( folder, $"{name}_fp_rao.png" ), rao, atlas, files );
var zLocked = settings.ZLockedCard || layout == FlatpackLayout.Cylinder;
var materialFile = Path.Combine( folder, $"{name}_fp.vmat" );
File.WriteAllText( materialFile, BuildMaterial(
Relative( assetsRoot, albedoFile ), Relative( assetsRoot, normalFile ), Relative( assetsRoot, depthFile ), depthBits,
Relative( assetsRoot, framesFile ), Relative( assetsRoot, coarseFile ), raoFile is null ? null : Relative( assetsRoot, raoFile ),
n, layout, center, radius, zLocked, mipAlphaScales, searchSteps, seeThrough > SeeThroughShare, bakedMetal ) );
files.Add( materialFile );
progress?.Invoke( 0.95f, "Compiling material" );
// Textures first, so the material compile finds them already built.
foreach ( var file in files )
{
var asset = AssetSystem.RegisterFile( file );
if ( asset is not null )
await asset.CompileIfNeededAsync( 120.0f );
}
var materialPath = Relative( assetsRoot, materialFile );
var material = Material.Load( materialPath );
// The resource: create it once, then overwrite its values on re-bakes so references to it survive.
var impostorFile = Path.Combine( folder, $"{name}.fpack" );
var impostorPath = Relative( assetsRoot, impostorFile );
var impostorAsset = AssetSystem.FindByPath( impostorPath );
if ( impostorAsset is null )
{
impostorAsset = AssetSystem.CreateResource( "fpack", impostorFile );
await impostorAsset.CompileIfNeededAsync();
}
if ( !impostorAsset.TryLoadResource<FlatpackImpostor>( out var impostor ) )
throw new InvalidOperationException( $"Couldn't create {impostorPath}." );
impostor.SourceModel = model;
impostor.Material = material;
impostor.Layout = layout;
impostor.GridSize = n;
impostor.AtlasSize = atlas;
impostor.Center = center;
impostor.Radius = radius;
impostor.Bounds = bounds;
impostor.ThinDetail = mipAlphaScales[1];
impostor.QualityLevel = settings.Quality.ToString();
impostor.HoldsUpFrom = 0.0f;
impostor.SeeThrough = seeThrough;
impostorAsset.SaveToDisk( impostor );
files.Add( impostorFile );
progress?.Invoke( 1.0f, $"Baked {name}" );
return new Result
{
Impostor = impostor,
ImpostorPath = impostorPath,
MaterialPath = materialPath,
Files = files,
Duration = timer.Elapsed,
Level = settings.Quality,
};
}
/// <summary>
/// Each view's bounds: its outline and depth range from the vertices when <paramref name="fitted"/>, else the whole
/// sphere. Padded by two texels so bilinear filtering at the outline stays inside the frame.
/// </summary>
static FlatpackMath.FrameFit[] FitFrames( Vector3[] points, Vector3 center, float radius, int n, int cell, FlatpackLayout layout, bool fitted )
{
var fits = new FlatpackMath.FrameFit[n * n];
for ( var y = 0; y < n; y++ )
{
for ( var x = 0; x < n; x++ )
{
fits[y * n + x] = fitted
? FlatpackMath.FitFrame( points, center, radius, FlatpackMath.FrameDirection( x, y, n, layout ), cell, 2 )
: FlatpackMath.DecodeFrameFit( FlatpackMath.EncodeFrameFit( FlatpackMath.FrameFit.Sphere ) );
}
}
return fits;
}
/// <summary>
/// Render every frame and copy it into the CPU atlases. Returns whether metalness went into the roughness/AO atlas.
/// </summary>
static async Task<bool> CaptureFramesAsync( Model model, FlatpackLayout layout, int n, int cell, int atlas, Vector3 center, float radius, FlatpackMath.FrameFit[] fits,
Color32[] albedo, Color32[] normal, float[] depth, Color32[] rao, bool selfShadow, int supersample, Action<float, string> progress, CancellationToken cancel )
{
var depthMaterial = Material.Create( "flatpack_bake_depth", DepthShader, true );
selfShadow &= rao is not null;
var scene = Scene.CreateEditorScene();
scene.Name = "Flatpack Bake";
try
{
ModelRenderer renderer;
CameraComponent camera;
DirectionalLight sun = null;
MetalPass metal = null;
using ( scene.Push() )
{
var source = new GameObject( true, "source" );
renderer = source.Components.Create<ModelRenderer>();
renderer.Model = model;
// Bake from the most detailed LOD, whatever the frame's screen size would pick.
renderer.LodOverride = 0;
var eye = new GameObject( true, "camera" );
camera = eye.Components.Create<CameraComponent>();
camera.BackgroundColor = Color.Transparent;
camera.Orthographic = true;
if ( selfShadow )
{
var light = new GameObject( true, "sky light" );
sun = light.Components.Create<DirectionalLight>();
sun.LightColor = Color.White;
sun.Shadows = true;
}
if ( rao is not null )
metal = MetalPass.Create( renderer );
}
var frame = new Bitmap( cell * supersample, cell * supersample, false );
var total = n * n;
var sliceTimer = Stopwatch.StartNew();
for ( var y = 0; y < n; y++ )
{
for ( var x = 0; x < n; x++ )
{
cancel.ThrowIfCancellationRequested();
var dir = FlatpackMath.FrameDirection( x, y, n, layout );
var fit = fits[y * n + x];
Color32[] colorPixels, normalPixels, roughPixels = null, aoPixels = null;
float[] depthPixels;
byte[] visibility = null, metalness = null;
using ( scene.Push() )
{
FrameCamera( camera, center, radius, dir, fit );
scene.EditorTick( 0.0f, 0.0f );
colorPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, null );
normalPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.NormalMap, null );
depthPixels = DecodeDepth( Capture( camera, renderer, frame, SceneCameraDebugMode.Albedo, depthMaterial ) );
if ( rao is not null )
{
roughPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.Roughness, null );
aoPixels = Capture( camera, renderer, frame, SceneCameraDebugMode.AmbientOcclusion, null );
}
if ( selfShadow )
visibility = CaptureSkyVisibility( scene, camera, renderer, sun, frame, normalPixels );
if ( metal is not null )
{
if ( sun is not null ) sun.Enabled = false;
metalness = metal.Capture( scene, camera, renderer, frame );
if ( sun is not null ) sun.Enabled = true;
}
}
if ( supersample > 1 )
Downsample( cell, supersample, ref colorPixels, ref normalPixels, ref depthPixels, ref roughPixels, ref aoPixels, ref visibility, ref metalness );
BlitFrame( x, y, cell, atlas, colorPixels, normalPixels, depthPixels, roughPixels, aoPixels, visibility, metalness, albedo, normal, depth, rao );
var done = y * n + x + 1;
progress?.Invoke( done / (float)total * 0.85f, $"Frame {done} of {total}" );
// Hand the main thread back now and then so the editor stays responsive.
if ( sliceTimer.ElapsedMilliseconds > 30 )
{
await Task.Delay( 1 );
sliceTimer.Restart();
}
}
}
return metal is not null;
}
finally
{
scene.Destroy();
}
}
/// <summary>
/// Directions the sky-visibility pass lights the model from: one overhead and two rings, spread over the upper
/// hemisphere. Fixed in object space, so every frame sees the same shadows and blending between frames stays seamless.
/// </summary>
static readonly Angles[] SkyLights =
{
new( 85, 0, 0 ),
new( 60, 0, 0 ), new( 60, 120, 0 ), new( 60, 240, 0 ),
new( 30, 45, 0 ), new( 30, 135, 0 ), new( 30, 225, 0 ), new( 30, 315, 0 ),
};
/// <summary>
/// How much of the sky each texel sees, 0..255: diffuse light with shadows over diffuse light without, both summed over
/// <see cref="SkyLights"/>. This is the self-shadowing a dense model (a tree's inner branches) has and a card can't
/// work out live; the shader uses it to darken ambient light and part of the albedo.
/// </summary>
/// <remarks>
/// Eight lights average out to soft occlusion; with only a few, a surface facing one light gets that light's hard
/// shadow and turns black. Ratios are taken in linear light.
/// </remarks>
static byte[] CaptureSkyVisibility( Scene scene, CameraComponent camera, ModelRenderer renderer, DirectionalLight sun, Bitmap target, Color32[] normals )
{
var count = target.Width * target.Height;
var lit = new float[count];
var open = new float[count];
foreach ( var angles in SkyLights )
{
sun.WorldRotation = angles;
foreach ( var shadowed in new[] { true, false } )
{
sun.Shadows = shadowed;
scene.EditorTick( 0.0f, 0.0f );
// Render twice: the first render after moving or toggling the light can still use the old shadow map.
Capture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );
var pixels = Capture( camera, renderer, target, SceneCameraDebugMode.Diffuse, null );
var sum = shadowed ? lit : open;
for ( var i = 0; i < count; i++ )
sum[i] += Linear( pixels[i].r );
}
}
// Shadow-map bias and terminator softness keep even fully open texels a little under 1, and that shifts from frame
// to frame. Normalise: the well-lit texels of this frame (95th percentile) count as fully visible. Capped, so a
// frame that really is mostly in shadow isn't brightened.
var ratio = new float[count];
var samples = new List<float>( count );
var openMax = 0.0f;
for ( var i = 0; i < count; i++ )
openMax = MathF.Max( openMax, open[i] );
for ( var i = 0; i < count; i++ )
{
ratio[i] = open[i] > 0.0f ? Math.Clamp( lit[i] / open[i], 0.0f, 1.0f ) : 1.0f;
if ( normals[i].a > 0 && open[i] > openMax * 0.2f )
samples.Add( ratio[i] );
}
samples.Sort();
var reference = samples.Count > 16 ? samples[(int)(samples.Count * 0.95f)] : 1.0f;
reference = Math.Clamp( reference, 0.75f, 1.0f );
var visibility = new byte[count];
for ( var i = 0; i < count; i++ )
{
// Texels the sky barely reaches (undersides) carry little information; fade them toward fully visible.
var known = openMax > 0.0f ? Math.Clamp( open[i] / (openMax * 0.2f), 0.0f, 1.0f ) : 0.0f;
var v = Math.Clamp( ratio[i] / reference, 0.0f, 1.0f );
visibility[i] = (byte)Math.Clamp( (int)MathF.Round( (v * known + (1.0f - known)) * 255.0f ), 0, 255 );
}
return visibility;
}
/// <summary>
/// The diffuse lighting view writes light through an sRGB target; ratios only make sense in linear light.
/// </summary>
static float Linear( byte value )
{
var c = value / 255.0f;
return c <= 0.04045f ? c / 12.92f : MathF.Pow( (c + 0.055f) / 1.055f, 2.4f );
}
/// <summary>
/// Point the bake camera at one frame: orthographic, framing exactly the frame's fitted image rectangle (wider or
/// taller than square when the object is), with the near plane on its nearest surface.
/// </summary>
static void FrameCamera( CameraComponent camera, Vector3 center, float radius, Vector3 dir, FlatpackMath.FrameFit fit )
{
FlatpackMath.FrameBasis( dir, out var right, out var up );
var size = (fit.Max - fit.Min) * (2.0f * radius);
var mid = (fit.Min + fit.Max) * 0.5f - new Vector2( 0.5f, 0.5f );
// Image v runs down, so the rectangle's centre is up by -v. The camera stays two radii out, outside the sphere.
var distance = radius * 2.0f;
camera.WorldPosition = center + right * (mid.x * 2.0f * radius) - up * (mid.y * 2.0f * radius) + dir * distance;
camera.WorldRotation = FlatpackMath.FrameRotation( dir );
// Depth 0..1 spans one bounding diameter back from the nearest surface, not just to the furthest one: the background
// then sits at least a diameter behind the object, where the shader's search never reaches, even in mips that
// average an outline with it. 16 bits over a diameter is still 256 times finer than the 8 bits it replaces.
var zNear = distance - fit.Near * radius;
var zFar = zNear + 2.0f * radius;
camera.OrthographicHeight = size.y;
camera.ZNear = zNear;
camera.ZFar = zFar;
// The engine's own orthographic camera is square for a square target; this is the same matrix (checked against
// CameraComponent.ProjectionMatrix in the editor) with its own width, so a tall object fills the frame both ways.
camera.CustomProjectionMatrix = new Matrix(
2.0f / size.x, 0, 0, 0,
0, 2.0f / size.y, 0, 0,
0, 0, -1.0f / (zFar - zNear), -zNear / (zFar - zNear),
0, 0, 0, 1 );
}
/// <summary>
/// The depth bake's red and green bytes back to one 0..1 depth: red is whole 1/255 steps, green the fraction of a step.
/// </summary>
static float[] DecodeDepth( Color32[] pixels )
{
var depth = new float[pixels.Length];
for ( var i = 0; i < pixels.Length; i++ )
depth[i] = Math.Clamp( (pixels[i].r + pixels[i].g / 255.0f) / 255.0f, 0.0f, 1.0f );
return depth;
}
static Color32[] Capture( CameraComponent camera, ModelRenderer renderer, Bitmap target, SceneCameraDebugMode mode, Material overrideMaterial )
{
camera.DebugMode = mode;
renderer.MaterialOverride = overrideMaterial;
camera.RenderToBitmap( target );
return target.GetPixels32();
}
/// <summary>
/// Bakes metalness. s&box has no metalness debug view and materials store it in different ways (a texture, a
/// constant, packed channels), so this solves for it instead of reading it: the Reflect view of the real materials
/// against copies forced to metalness 0 and 1, all lit by one light from the camera. Per texel,
/// metal = (real - dielectric) / (metal - dielectric).
/// </summary>
sealed class MetalPass
{
Material[] _original, _dielectric, _metal;
DirectionalLight _light;
/// <summary>A pass for <paramref name="renderer"/>'s model, or null when none of its materials can be metal.</summary>
public static MetalPass Create( ModelRenderer renderer )
{
var count = renderer.Materials.Count;
var pass = new MetalPass { _original = new Material[count], _dielectric = new Material[count], _metal = new Material[count] };
var any = false;
for ( var i = 0; i < count; i++ )
{
var source = renderer.Materials.GetOriginal( i );
pass._original[i] = source;
if ( source is null )
continue;
any |= source.GetFeature( "F_METALNESS_TEXTURE" ) != 0 || source.GetVector4( "g_flMetalness" ).x > 0.0f;
pass._dielectric[i] = Forced( source, 0.0f, i );
pass._metal[i] = Forced( source, 1.0f, i );
}
if ( !any )
return null;
pass._light = new GameObject( true, "metal light" ).Components.Create<DirectionalLight>();
pass._light.LightColor = Color.White;
pass._light.Shadows = false;
pass._light.Enabled = false;
return pass;
}
static Material Forced( Material source, float metalness, int slot )
{
var copy = source.CreateCopy( $"flatpack_metal_{metalness:0}_{slot}" );
copy.SetFeature( "F_METALNESS_TEXTURE", 0 );
copy.Set( "g_flMetalness", metalness );
return copy;
}
/// <summary>Metalness per pixel of <paramref name="target"/>, 0..255. Call inside the bake scene.</summary>
public byte[] Capture( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target )
{
_light.WorldRotation = camera.WorldRotation;
_light.Enabled = true;
var real = Render( scene, camera, renderer, target, _original );
var low = Render( scene, camera, renderer, target, _dielectric );
var high = Render( scene, camera, renderer, target, _metal );
_light.Enabled = false;
renderer.ClearMaterialOverrides();
var w = target.Width;
var h = target.Height;
var count = real.Length;
var value = new float[count];
var known = new bool[count];
for ( var i = 0; i < count; i++ )
{
if ( real[i].a < 128 )
continue;
float r = Brightness( real[i] ), d = Brightness( low[i] ), m = Brightness( high[i] );
// Where metal and dielectric reflect alike (dark colours, grazing angles) the solve has nothing to go on;
// metalness barely changes the look there anyway. Those texels borrow their neighbours' value below.
if ( MathF.Abs( m - d ) < 6.0f )
continue;
value[i] = Math.Clamp( (r - d) / (m - d), 0.0f, 1.0f );
known[i] = true;
}
// A few rounds of filling unknown texels from known neighbours; anything still unknown is non-metal.
for ( var round = 0; round < 6; round++ )
{
var next = (bool[])known.Clone();
for ( var i = 0; i < count; i++ )
{
if ( known[i] || real[i].a < 128 )
continue;
int x = i % w, y = i / w, n = 0;
var sum = 0.0f;
for ( var dy = -1; dy <= 1; dy++ )
{
for ( var dx = -1; dx <= 1; dx++ )
{
int nx = x + dx, ny = y + dy;
if ( nx < 0 || ny < 0 || nx >= w || ny >= h ) continue;
var j = ny * w + nx;
if ( !known[j] ) continue;
sum += value[j];
n++;
}
}
if ( n == 0 )
continue;
value[i] = sum / n;
next[i] = true;
}
known = next;
}
var bytes = new byte[count];
for ( var i = 0; i < count; i++ )
bytes[i] = (byte)Math.Clamp( (int)MathF.Round( value[i] * 255.0f ), 0, 255 );
return bytes;
}
static Color32[] Render( Scene scene, CameraComponent camera, ModelRenderer renderer, Bitmap target, Material[] materials )
{
for ( var i = 0; i < materials.Length; i++ )
{
if ( materials[i] is not null )
renderer.Materials.SetOverride( i, materials[i] );
}
renderer.Materials.Apply();
scene.EditorTick( 0.0f, 0.0f );
return FlatpackBaker.Capture( camera, renderer, target, SceneCameraDebugMode.Reflect, null );
}
static float Brightness( Color32 c ) => (c.r + c.g + c.b) / 3.0f;
}
/// <summary>
/// Average a supersampled frame down to <paramref name="cell"/> texels a side. Alpha becomes the share of covered
/// samples; colour, normal, depth and the material channels average over the covered samples only, so the empty
/// background doesn't darken thin parts.
/// </summary>
static void Downsample( int cell, int ss, ref Color32[] color, ref Color32[] normal, ref float[] depth, ref Color32[] rough, ref Color32[] ao, ref byte[] visibility, ref byte[] metal )
{
var size = cell * ss;
var outColor = new Color32[cell * cell];
var outNormal = new Color32[cell * cell];
var outDepth = new float[cell * cell];
var outRough = rough is null ? null : new Color32[cell * cell];
var outAo = ao is null ? null : new Color32[cell * cell];
var outVis = visibility is null ? null : new byte[cell * cell];
var outMetal = metal is null ? null : new byte[cell * cell];
for ( var y = 0; y < cell; y++ )
{
for ( var x = 0; x < cell; x++ )
{
float r = 0, g = 0, b = 0, a = 0, nx = 0, ny = 0, nz = 0, d = 0, ro = 0, oc = 0, vi = 0, me = 0;
var covered = 0;
for ( var sy = 0; sy < ss; sy++ )
{
for ( var sx = 0; sx < ss; sx++ )
{
var i = (y * ss + sy) * size + x * ss + sx;
var c = color[i];
// The albedo view writes the material's raw opacity. Alpha-tested textures keep their small mips
// above the cut on purpose, so averaging that raw value overstates thin detail (a fence panel that's
// 12% wire read as 44%). Cut each sample the way the game cuts the real model, then average.
if ( c.a < AlphaTestCut )
continue;
a += 255;
covered++;
r += c.r; g += c.g; b += c.b;
nx += normal[i].r / 127.5f - 1.0f; ny += normal[i].g / 127.5f - 1.0f; nz += normal[i].b / 127.5f - 1.0f;
d += depth[i];
if ( rough is not null ) ro += rough[i].r;
if ( ao is not null ) oc += ao[i].r;
if ( visibility is not null ) vi += visibility[i];
if ( metal is not null ) me += metal[i];
}
}
var o = y * cell + x;
if ( covered == 0 )
continue;
byte B( float v ) => (byte)Math.Clamp( (int)MathF.Round( v ), 0, 255 );
var alpha = B( a / (ss * ss) );
outColor[o] = new Color32( B( r / covered ), B( g / covered ), B( b / covered ), Math.Max( alpha, (byte)1 ) );
var len = MathF.Sqrt( nx * nx + ny * ny + nz * nz );
if ( len < 1e-4f ) len = 1.0f;
outNormal[o] = new Color32( B( (nx / len + 1.0f) * 127.5f ), B( (ny / len + 1.0f) * 127.5f ), B( (nz / len + 1.0f) * 127.5f ), 255 );
outDepth[o] = d / covered;
if ( outRough is not null ) { var v = B( ro / covered ); outRough[o] = new Color32( v, v, v, 255 ); }
if ( outAo is not null ) { var v = B( oc / covered ); outAo[o] = new Color32( v, v, v, 255 ); }
if ( outVis is not null ) outVis[o] = B( vi / covered );
if ( outMetal is not null ) outMetal[o] = B( me / covered );
}
}
color = outColor; normal = outNormal; depth = outDepth; rough = outRough; ao = outAo; visibility = outVis; metal = outMetal;
}
/// <summary>
/// Copy one frame's passes into the atlases. Uncovered texels stay fully clear (depth at the far plane) for the
/// dilation pass.
/// </summary>
static void BlitFrame( int fx, int fy, int cell, int atlas, Color32[] color, Color32[] normal, float[] depth, Color32[] rough, Color32[] ao,
byte[] visibility, byte[] metal, Color32[] albedoAtlas, Color32[] normalAtlas, float[] depthAtlas, Color32[] raoAtlas )
{
for ( var py = 0; py < cell; py++ )
{
var row = (fy * cell + py) * atlas + fx * cell;
for ( var px = 0; px < cell; px++ )
{
var src = py * cell + px;
var dst = row + px;
var c = color[src];
if ( c.a == 0 )
{
albedoAtlas[dst] = default;
normalAtlas[dst] = new Color32( 128, 128, 255, 255 );
depthAtlas[dst] = 1.0f;
if ( raoAtlas is not null ) raoAtlas[dst] = default;
continue;
}
albedoAtlas[dst] = c;
normalAtlas[dst] = new Color32( normal[src].r, normal[src].g, normal[src].b, 255 );
depthAtlas[dst] = depth[src];
// Metalness rides in alpha as 1 + metal x 254 (never 0, which would wipe the colour; see the shader).
if ( raoAtlas is not null )
raoAtlas[dst] = new Color32( rough[src].r, ao[src].r, visibility?[src] ?? 255, metal is null ? (byte)255 : (byte)(1 + metal[src] * 254 / 255) );
}
}
}
/// <summary>
/// Grow every frame's colours outward from its silhouette, staying inside the frame, so bilinear filtering and
/// mips near the edge read the object's own colour instead of the empty background.
/// </summary>
/// <remarks>
/// One multi-source breadth-first fill over the whole atlas: each empty texel takes the values of the nearest
/// covered texel (by steps) in its own frame. Albedo alpha on grown texels is 1/255, far below the alpha clip,
/// but non-zero so the colour survives the PNG round trip.
/// </remarks>
static void DilateIntoFrames( Color32[] albedo, Color32[] normal, float[] depth, Color32[] rao, int atlas, int cell, int padding )
{
var maxSteps = padding <= 0 ? int.MaxValue : padding;
var source = new int[albedo.Length];
var steps = new int[albedo.Length];
var queue = new Queue<int>( albedo.Length / 4 );
for ( var i = 0; i < albedo.Length; i++ )
{
if ( albedo[i].a > 0 )
{
source[i] = i;
queue.Enqueue( i );
}
else
{
source[i] = -1;
}
}
while ( queue.Count > 0 )
{
var i = queue.Dequeue();
if ( steps[i] >= maxSteps )
continue;
var x = i % atlas;
var y = i / atlas;
// Neighbours in the same frame only, never across a frame border.
if ( x % cell > 0 ) Grow( i, i - 1 );
if ( x % cell < cell - 1 ) Grow( i, i + 1 );
if ( y % cell > 0 ) Grow( i, i - atlas );
if ( y % cell < cell - 1 ) Grow( i, i + atlas );
}
void Grow( int from, int to )
{
if ( source[to] >= 0 )
return;
var s = source[from];
source[to] = s;
steps[to] = steps[from] + 1;
queue.Enqueue( to );
var c = albedo[s];
albedo[to] = new Color32( c.r, c.g, c.b, 1 );
// Grown texels keep the neighbour's normal but sit at the far plane, so the shader's depth search passes
// straight through the background instead of stopping on borrowed depth and drawing a ghost.
normal[to] = normal[s];
depth[to] = 1.0f;
if ( rao is not null ) rao[to] = rao[s];
}
}
static string WritePng( string path, Color32[] pixels, int size, List<string> files ) => WritePng( path, pixels, size, size, files );
static string WritePng( string path, Color32[] pixels, int width, int height, List<string> files )
{
using var bitmap = new Bitmap( width, height, false );
var colors = new Color[pixels.Length];
for ( var i = 0; i < pixels.Length; i++ )
colors[i] = pixels[i];
bitmap.SetPixels( colors );
File.WriteAllBytes( path, bitmap.ToPng() );
files.Add( path );
return path;
}
/// <summary>The 16-bit depth atlas, as a float image the texture compiler turns into R16.</summary>
static string WritePfm( string path, float[] depth, int size, List<string> files )
{
FlatpackPfm.WriteGray( path, depth, size, size );
files.Add( path );
return path;
}
/// <summary>Depth rounded to bytes, for the 8-bit (BC4) depth texture.</summary>
static Color32[] DepthBytes( float[] depth )
{
var bytes = new Color32[depth.Length];
for ( var i = 0; i < depth.Length; i++ )
{
var v = (byte)Math.Clamp( (int)MathF.Round( depth[i] * 255.0f ), 0, 255 );
bytes[i] = new Color32( v, v, v, 255 );
}
return bytes;
}
/// <summary>Texels per side of one coarse depth block (written into the material as <c>g_flFlatpackCoarseBlock</c>).</summary>
public const int CoarseBlock = 16;
/// <summary>
/// The coarse depth map: for each <see cref="CoarseBlock"/> x <see cref="CoarseBlock"/> texel block of each frame, the
/// nearest depth in it (the smallest value) less <paramref name="margin"/>, laid out per frame like the atlas
/// (frame x, y at blocks x, y of a <paramref name="size"/>-wide square). The shader's search skips every block whose
/// nearest surface the ray never gets down to, and marches the rest a texel at a time.
/// </summary>
static float[] CoarseDepth( float[] depth, int atlas, int cell, int n, float margin, out int size )
{
var blocks = (cell + CoarseBlock - 1) / CoarseBlock;
size = blocks * n;
var coarse = new float[size * size];
for ( var fy = 0; fy < n; fy++ )
for ( var fx = 0; fx < n; fx++ )
for ( var by = 0; by < blocks; by++ )
for ( var bx = 0; bx < blocks; bx++ )
{
var nearest = 1.0f;
for ( var y = by * CoarseBlock; y < Math.Min( (by + 1) * CoarseBlock, cell ); y++ )
{
var row = (fy * cell + y) * atlas + fx * cell;
for ( var x = bx * CoarseBlock; x < Math.Min( (bx + 1) * CoarseBlock, cell ); x++ )
nearest = MathF.Min( nearest, depth[row + x] );
}
coarse[(fy * blocks + by) * size + fx * blocks + bx] = MathF.Max( 0.0f, nearest - margin );
}
return coarse;
}
/// <summary>
/// The frame lookup texture: two texels per frame (see <see cref="FlatpackMath.EncodeFrameFit"/>), image rectangles in
/// the left half, depth ranges <see cref="FlatpackMath.FrameFitColumns"/> texels to the right.
/// </summary>
static string WriteFrames( string path, FlatpackMath.FrameFit[] fits, int n, List<string> files )
{
const int width = FlatpackMath.FrameFitColumns * 2;
const int height = FlatpackMath.FrameFitColumns;
var pixels = new Color32[width * height];
for ( var i = 0; i < pixels.Length; i++ )
pixels[i] = new Color32( 0, 0, 255, 255 );
for ( var y = 0; y < n; y++ )
{
for ( var x = 0; x < n; x++ )
{
var (rect, range) = FlatpackMath.EncodeFrameFit( fits[y * n + x] );
pixels[y * width + x] = rect;
pixels[y * width + x + FlatpackMath.FrameFitColumns] = range;
}
}
return WritePng( path, pixels, width, height, files );
}
/// <summary>
/// Per-mip alpha multipliers that keep the share of covered texels the same as at full resolution.
/// </summary>
/// <remarks>
/// Mipmaps average a thin part's alpha with the empty space around it. Past a mip or two, spindles, wires and leaf
/// tips fall below the 0.5 clip and the card loses them (or they sparkle). For each mip level we find the alpha
/// that the same share of texels exceeds as were above 0.5 at full size, and store the factor that lifts it back to
/// 0.5. A fixed boost instead over-fills foliage and fences into solid blobs. Mips are approximated with a 2x2 box
/// filter, as the texture compiler builds them.
/// </remarks>
static float[] MipAlphaScales( Color32[] albedo, int size )
{
const int levels = 8;
var scales = new float[levels];
var alpha = new float[albedo.Length];
var covered = 0;
for ( var i = 0; i < albedo.Length; i++ )
{
alpha[i] = albedo[i].a / 255.0f;
if ( alpha[i] > 0.5f ) covered++;
}
var share = covered / (float)albedo.Length;
var w = size;
var h = size;
for ( var level = 0; level < levels; level++ )
{
var nw = Math.Max( 1, w / 2 );
var nh = Math.Max( 1, h / 2 );
var next = new float[nw * nh];
for ( var y = 0; y < nh; y++ )
{
for ( var x = 0; x < nw; x++ )
{
var x0 = Math.Min( x * 2, w - 1 ); var x1 = Math.Min( x * 2 + 1, w - 1 );
var y0 = Math.Min( y * 2, h - 1 ); var y1 = Math.Min( y * 2 + 1, h - 1 );
next[y * nw + x] = (alpha[y0 * w + x0] + alpha[y0 * w + x1] + alpha[y1 * w + x0] + alpha[y1 * w + x1]) * 0.25f;
}
}
alpha = next; w = nw; h = nh;
// The alpha value with `share` of this mip's texels above it.
var sorted = (float[])alpha.Clone();
Array.Sort( sorted );
var index = Math.Clamp( (int)((1.0f - share) * sorted.Length), 0, sorted.Length - 1 );
var threshold = sorted[index];
scales[level] = threshold > 0.01f ? Math.Clamp( 0.5f / threshold, 1.0f, 4.0f ) : 1.0f;
}
return scales;
}
static string BuildMaterial( string albedo, string normal, string depth, int depthBits, string frames, string coarse, string rao, int n, FlatpackLayout layout, Vector3 center, float radius,
bool zLocked, float[] mipAlphaScales, int searchSteps, bool seeThrough, bool bakedMetal )
{
string F( float v ) => v.ToString( "0.000000", CultureInfo.InvariantCulture );
var sb = new StringBuilder();
sb.AppendLine( "// Flatpack impostor material. Generated by the Flatpack baker (Anakin Studio); re-baking overwrites it." );
sb.AppendLine( "Layer0" );
sb.AppendLine( "{" );
sb.AppendLine( $"\tshader \"{ImpostorShader}\"" );
sb.AppendLine( $"\tTextureAlbedo \"{albedo}\"" );
sb.AppendLine( $"\tTextureNormal \"{normal}\"" );
sb.AppendLine( depthBits == 16 ? $"\tTextureDepth \"{depth}\"" : $"\tTextureDepthBC4 \"{depth}\"" );
sb.AppendLine( $"\tTextureFrames \"{frames}\"" );
sb.AppendLine( $"\tTextureDepthMin \"{coarse}\"" );
sb.AppendLine( $"\tg_flFlatpackCoarse \"{F( 1 )}\"" );
sb.AppendLine( $"\tg_flFlatpackCoarseBlock \"{F( CoarseBlock )}\"" );
sb.AppendLine( $"\tg_flFlatpackFramed \"{F( 1 )}\"" );
sb.AppendLine( $"\tg_flFlatpackDepthBits \"{F( depthBits )}\"" );
if ( rao is not null )
sb.AppendLine( $"\tTextureRoughnessAo \"{rao}\"" );
sb.AppendLine( $"\tg_flFlatpackGrid \"{F( n )}\"" );
sb.AppendLine( $"\tg_flFlatpackMode \"{F( (int)layout )}\"" );
sb.AppendLine( $"\tg_vFlatpackCenter \"[{F( center.x )} {F( center.y )} {F( center.z )}]\"" );
sb.AppendLine( $"\tg_flFlatpackRadius \"{F( radius )}\"" );
sb.AppendLine( $"\tg_flFlatpackZLocked \"{F( zLocked ? 1 : 0 )}\"" );
sb.AppendLine( $"\tg_vFlatpackMipAlpha0 \"[{F( mipAlphaScales[0] )} {F( mipAlphaScales[1] )} {F( mipAlphaScales[2] )} {F( mipAlphaScales[3] )}]\"" );
sb.AppendLine( $"\tg_vFlatpackMipAlpha1 \"[{F( mipAlphaScales[4] )} {F( mipAlphaScales[5] )} {F( mipAlphaScales[6] )} {F( mipAlphaScales[7] )}]\"" );
sb.AppendLine( $"\tg_flFlatpackSearchSteps \"{F( Math.Clamp( searchSteps, 0, 32 ) )}\"" );
// See-through bakes start from a typical fit; the bake check fits both to the model after baking.
sb.AppendLine( $"\tg_flFlatpackSoftCoverage \"{F( seeThrough ? 1.5f : 0.0f )}\"" );
sb.AppendLine( $"\tg_flFlatpackSoftGrowth \"{F( seeThrough ? 0.5f : 0.0f )}\"" );
sb.AppendLine( $"\tg_flFlatpackBakedMetal \"{F( bakedMetal ? 1.0f : 0.0f )}\"" );
sb.AppendLine( "}" );
return sb.ToString();
}
/// <summary>
/// Where the outputs go, and the Assets folder that asset paths are relative to.
/// </summary>
static (string Folder, string AssetsRoot) ResolveOutputFolder( Model model, FlatpackBakeSettings settings )
{
var projectAssets = Project.Current.GetAssetsPath();
if ( !string.IsNullOrWhiteSpace( settings.OutputFolder ) )
{
var sub = settings.OutputFolder.Trim().Trim( '/', '\\' );
return (Path.GetFullPath( Path.Combine( projectAssets, sub ) ), projectAssets);
}
// Next to the source, but only when the source is a file in this project (or one of its libraries).
// Engine, cloud and cached models are read-only to us.
var source = AssetSystem.FindByPath( model.ResourcePath );
var projectRoot = Path.GetFullPath( Project.Current.GetRootPath() );
var absolute = source?.AbsolutePath;
if ( !string.IsNullOrEmpty( absolute ) )
{
absolute = Path.GetFullPath( absolute );
var inProject = absolute.StartsWith( projectRoot, StringComparison.OrdinalIgnoreCase )
&& !absolute.Contains( $"{Path.DirectorySeparatorChar}.sbox{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase );
var assetsRoot = inProject ? FindAssetsRoot( absolute ) : null;
if ( assetsRoot is not null )
return (Path.GetDirectoryName( absolute ), assetsRoot);
}
return (Path.Combine( projectAssets, "flatpack" ), projectAssets);
}
static string FindAssetsRoot( string file )
{
var dir = new DirectoryInfo( Path.GetDirectoryName( file ) );
while ( dir is not null )
{
if ( dir.Name.Equals( "Assets", StringComparison.OrdinalIgnoreCase ) )
return dir.FullName;
dir = dir.Parent;
}
return null;
}
static string Relative( string assetsRoot, string file )
{
return Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' ).ToLowerInvariant();
}
static string SafeName( Model model )
{
var name = Path.GetFileNameWithoutExtension( model.ResourcePath ?? model.Name ?? "model" );
var invalid = Path.GetInvalidFileNameChars();
return new string( name.Select( c => invalid.Contains( c ) || c == ' ' ? '_' : char.ToLowerInvariant( c ) ).ToArray() );
}
}