Created
March 19, 2026 10:01
-
-
Save DoctorGester/88ee4992f45b3992e55566b824fbc333 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #import <AppKit/AppKit.h> | |
| #import <Metal/Metal.h> | |
| #import <MetalKit/MetalKit.h> | |
| #import <QuartzCore/QuartzCore.h> | |
| #include <ft2build.h> | |
| #include FT_FREETYPE_H | |
| #include FT_OUTLINE_H | |
| #define KB_TEXT_SHAPE_IMPLEMENTATION | |
| #include "../vendor/kb/kb_text_shape.h" | |
| #include <algorithm> | |
| #include <array> | |
| #include <chrono> | |
| #include <cmath> | |
| #include <cstdint> | |
| #include <filesystem> | |
| #include <iomanip> | |
| #include <iostream> | |
| #include <limits> | |
| #include <map> | |
| #include <memory> | |
| #include <sstream> | |
| #include <simd/simd.h> | |
| #include <stdexcept> | |
| #include <string> | |
| #include <string_view> | |
| #include <vector> | |
| namespace slug | |
| { | |
| namespace fs = std::filesystem; | |
| constexpr uint32_t kParagraphWidth = 900; | |
| constexpr uint32_t kFontSizePixels = 48; | |
| constexpr float kParagraphPadding = 48.0f; | |
| constexpr float kBandDilation = 0.0f; | |
| struct CurveData | |
| { | |
| float p1[2]; | |
| float p2[2]; | |
| float p3[2]; | |
| float minX; | |
| float minY; | |
| float maxX; | |
| float maxY; | |
| }; | |
| struct LiveCurveData | |
| { | |
| float p1[2]; | |
| __fp16 a[2]; | |
| __fp16 b[2]; | |
| __fp16 minX; | |
| __fp16 minY; | |
| __fp16 maxX; | |
| __fp16 maxY; | |
| }; | |
| struct BandHeader | |
| { | |
| uint32_t count; | |
| uint32_t offset; | |
| uint32_t negOffset; | |
| float splitCoord; | |
| }; | |
| struct RenderConfig | |
| { | |
| float glyphMinX = 0.0f; | |
| float glyphMinY = 0.0f; | |
| float glyphMaxX = 0.0f; | |
| float glyphMaxY = 0.0f; | |
| float hScale = 1.0f; | |
| float hOffset = 0.0f; | |
| float vScale = 1.0f; | |
| float vOffset = 0.0f; | |
| uint32_t hBandCount = 1; | |
| uint32_t vBandCount = 1; | |
| }; | |
| struct QuadraticCurve | |
| { | |
| std::array<float, 2> p1 {}; | |
| std::array<float, 2> p2 {}; | |
| std::array<float, 2> p3 {}; | |
| float minX = 0.0f; | |
| float minY = 0.0f; | |
| float maxX = 0.0f; | |
| float maxY = 0.0f; | |
| }; | |
| struct PolygonVertex | |
| { | |
| float position[2]; | |
| float normal[2]; | |
| }; | |
| struct GlyphPreparation | |
| { | |
| std::vector<CurveData> curves; | |
| std::vector<BandHeader> hHeaders; | |
| std::vector<uint32_t> hIndices; | |
| std::vector<BandHeader> vHeaders; | |
| std::vector<uint32_t> vIndices; | |
| std::vector<PolygonVertex> polygonVertices; | |
| RenderConfig config {}; | |
| }; | |
| struct FontResource | |
| { | |
| size_t index = 0; | |
| fs::path path; | |
| FT_Face face = nullptr; | |
| kbts_shape_context *shapeContext = nullptr; | |
| kbts_font *shapeFont = nullptr; | |
| kbts_font_info2_1 fontInfo {}; | |
| float pixelsPerUnit = 1.0f; | |
| float ascentPx = 0.0f; | |
| float descentPx = 0.0f; | |
| float lineGapPx = 0.0f; | |
| }; | |
| struct StyledSpan | |
| { | |
| size_t fontIndex = 0; | |
| std::string text; | |
| }; | |
| struct TextToken | |
| { | |
| size_t fontIndex = 0; | |
| std::string text; | |
| bool whitespace = false; | |
| bool newline = false; | |
| }; | |
| struct GlyphPlacement | |
| { | |
| size_t fontIndex = 0; | |
| uint32_t glyphIndex = 0; | |
| float offsetX = 0.0f; | |
| float offsetY = 0.0f; | |
| }; | |
| struct ShapedToken | |
| { | |
| size_t fontIndex = 0; | |
| std::string text; | |
| bool whitespace = false; | |
| bool newline = false; | |
| float advanceX = 0.0f; | |
| std::vector<GlyphPlacement> glyphs; | |
| }; | |
| struct PositionedGlyph | |
| { | |
| size_t fontIndex = 0; | |
| uint32_t glyphIndex = 0; | |
| float originX = 0.0f; | |
| float originY = 0.0f; | |
| }; | |
| struct ParagraphLayout | |
| { | |
| float contentWidth = 0.0f; | |
| float canvasWidth = 0.0f; | |
| float canvasHeight = 0.0f; | |
| float lineAdvance = 0.0f; | |
| float maxAscent = 0.0f; | |
| float maxDescent = 0.0f; | |
| std::vector<PositionedGlyph> glyphs; | |
| uint32_t lineCount = 0; | |
| }; | |
| struct GlyphKey | |
| { | |
| size_t fontIndex = 0; | |
| uint32_t glyphIndex = 0; | |
| bool operator<(const GlyphKey &other) const | |
| { | |
| if (fontIndex != other.fontIndex) | |
| { | |
| return fontIndex < other.fontIndex; | |
| } | |
| return glyphIndex < other.glyphIndex; | |
| } | |
| }; | |
| struct PageVertex | |
| { | |
| simd_float3 position; | |
| simd_float2 texCoord; | |
| }; | |
| struct LiveGlyphVertex | |
| { | |
| simd_float4 posNorm; | |
| simd_float2 sampleCoord; | |
| simd_float4 jacobian; | |
| simd_float4 banding; | |
| simd_uint4 glyph; | |
| }; | |
| struct SceneUniforms | |
| { | |
| simd_float4 row0; | |
| simd_float4 row1; | |
| simd_float4 row2; | |
| simd_float4 row3; | |
| simd_float2 viewport; | |
| simd_float2 pad; | |
| simd_float4 inkColor; | |
| simd_float4 paperColor; | |
| }; | |
| struct LiveFrameGlyphInfo | |
| { | |
| GlyphPreparation *prep = nullptr; | |
| uint32_t hHeaderBase = 0; | |
| uint32_t vHeaderBase = 0; | |
| }; | |
| struct LiveFrame | |
| { | |
| std::vector<PageVertex> pageVertices; | |
| std::vector<LiveGlyphVertex> glyphVertices; | |
| std::vector<LiveCurveData> curves; | |
| std::vector<BandHeader> hHeaders; | |
| std::vector<uint32_t> hIndices; | |
| std::vector<BandHeader> vHeaders; | |
| std::vector<uint32_t> vIndices; | |
| float pageAspect = 1.0f; | |
| double shapeMs = 0.0; | |
| double prepMs = 0.0; | |
| size_t glyphCount = 0; | |
| size_t uniqueGlyphCount = 0; | |
| }; | |
| struct CameraState | |
| { | |
| float yaw = 0.0f; | |
| float pitch = 0.18f; | |
| float distance = 3.6f; | |
| }; | |
| struct HudFrame | |
| { | |
| std::vector<LiveGlyphVertex> glyphVertices; | |
| std::vector<LiveCurveData> curves; | |
| std::vector<BandHeader> hHeaders; | |
| std::vector<uint32_t> hIndices; | |
| std::vector<BandHeader> vHeaders; | |
| std::vector<uint32_t> vIndices; | |
| }; | |
| struct HudMetrics | |
| { | |
| double fps = 0.0; | |
| double cpuFrameMs = 0.0; | |
| double gpuFrameMs = 0.0; | |
| double sceneShapeMs = 0.0; | |
| double scenePrepMs = 0.0; | |
| size_t glyphCount = 0; | |
| size_t uniqueGlyphCount = 0; | |
| }; | |
| struct AppRuntime | |
| { | |
| id<MTLDevice> device = nil; | |
| std::unique_ptr<class LiveParagraphRenderer> liveRenderer; | |
| std::unique_ptr<class HudTextRenderer> hudRenderer; | |
| }; | |
| static constexpr const char *kShaderSource = R"METAL( | |
| #include <metal_stdlib> | |
| using namespace metal; | |
| struct PageVertexIn | |
| { | |
| float3 position [[attribute(0)]]; | |
| float2 texCoord [[attribute(1)]]; | |
| }; | |
| struct GlyphVertexIn | |
| { | |
| float4 posNorm [[attribute(0)]]; | |
| float2 sampleCoord [[attribute(1)]]; | |
| float4 jacobian [[attribute(2)]]; | |
| float4 banding [[attribute(3)]]; | |
| uint4 glyph [[attribute(4)]]; | |
| }; | |
| struct LiveCurveData | |
| { | |
| float2 p1; | |
| half2 a; | |
| half2 b; | |
| half minX; | |
| half minY; | |
| half maxX; | |
| half maxY; | |
| }; | |
| struct BandHeader | |
| { | |
| uint count; | |
| uint offset; | |
| uint negOffset; | |
| float splitCoord; | |
| }; | |
| struct SceneUniforms | |
| { | |
| float4 row0; | |
| float4 row1; | |
| float4 row2; | |
| float4 row3; | |
| float2 viewport; | |
| float2 pad; | |
| float4 inkColor; | |
| float4 paperColor; | |
| }; | |
| struct PageVertexOut | |
| { | |
| float4 position [[position]]; | |
| float2 texCoord; | |
| }; | |
| struct GlyphVertexOut | |
| { | |
| float4 position [[position]]; | |
| float2 renderCoord; | |
| float4 banding [[flat]]; | |
| uint4 glyph [[flat]]; | |
| }; | |
| static inline float saturate1(float value) | |
| { | |
| return clamp(value, 0.0f, 1.0f); | |
| } | |
| static inline uint calcRootCode(float y1, float y2, float y3) | |
| { | |
| uint i1 = as_type<uint>(y1) >> 31u; | |
| uint i2 = as_type<uint>(y2) >> 30u; | |
| uint i3 = as_type<uint>(y3) >> 29u; | |
| uint shift = (i2 & 2u) | (i1 & ~2u); | |
| shift = (i3 & 4u) | (shift & ~4u); | |
| return ((0x2E74u >> shift) & 0x0101u); | |
| } | |
| static inline float2 solveHorizPoly(float2 p1, float2 a, float2 b) | |
| { | |
| float ra = 1.0f / a.y; | |
| float rb = 0.5f / b.y; | |
| float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0f)); | |
| float t1 = (b.y - d) * ra; | |
| float t2 = (b.y + d) * ra; | |
| if (fabs(a.y) < (1.0f / 65536.0f)) | |
| { | |
| t1 = p1.y * rb; | |
| t2 = t1; | |
| } | |
| return float2((a.x * t1 - b.x * 2.0f) * t1 + p1.x, | |
| (a.x * t2 - b.x * 2.0f) * t2 + p1.x); | |
| } | |
| static inline float2 solveVertPoly(float2 p1, float2 a, float2 b) | |
| { | |
| float ra = 1.0f / a.x; | |
| float rb = 0.5f / b.x; | |
| float d = sqrt(max(b.x * b.x - a.x * p1.x, 0.0f)); | |
| float t1 = (b.x - d) * ra; | |
| float t2 = (b.x + d) * ra; | |
| if (fabs(a.x) < (1.0f / 65536.0f)) | |
| { | |
| t1 = p1.x * rb; | |
| t2 = t1; | |
| } | |
| return float2((a.y * t1 - b.y * 2.0f) * t1 + p1.y, | |
| (a.y * t2 - b.y * 2.0f) * t2 + p1.y); | |
| } | |
| static inline float calcCoverage(float xcov, float ycov, float xwgt, float ywgt) | |
| { | |
| float blended = fabs(xcov * xwgt + ycov * ywgt) / max(xwgt + ywgt, 1.0f / 65536.0f); | |
| return saturate1(max(blended, min(fabs(xcov), fabs(ycov)))); | |
| } | |
| static inline float positiveCoverage(float root) | |
| { | |
| return saturate1(root + 0.5f); | |
| } | |
| static inline float2 slugDilate(float4 posNorm, | |
| float2 sampleCoord, | |
| float4 jacobian, | |
| constant SceneUniforms &uniforms, | |
| thread float2 &vpos) | |
| { | |
| float2 normal = normalize(posNorm.zw); | |
| float s = dot(uniforms.row3.xy, posNorm.xy) + uniforms.row3.w; | |
| float t = dot(uniforms.row3.xy, normal); | |
| float u = (s * dot(uniforms.row0.xy, normal) - t * (dot(uniforms.row0.xy, posNorm.xy) + uniforms.row0.w)) * uniforms.viewport.x; | |
| float v = (s * dot(uniforms.row1.xy, normal) - t * (dot(uniforms.row1.xy, posNorm.xy) + uniforms.row1.w)) * uniforms.viewport.y; | |
| float s2 = s * s; | |
| float st = s * t; | |
| float uv = u * u + v * v; | |
| float2 delta = posNorm.zw * (s2 * (st + sqrt(max(uv, 0.0f))) / max(uv - st * st, 1.0e-6f)); | |
| vpos = posNorm.xy + delta; | |
| return float2(sampleCoord.x + dot(delta, jacobian.xy), | |
| sampleCoord.y + dot(delta, jacobian.zw)); | |
| } | |
| static inline float4 clipPosition(float3 point, constant SceneUniforms &uniforms) | |
| { | |
| return float4(dot(uniforms.row0.xyz, point) + uniforms.row0.w, | |
| dot(uniforms.row1.xyz, point) + uniforms.row1.w, | |
| dot(uniforms.row2.xyz, point) + uniforms.row2.w, | |
| dot(uniforms.row3.xyz, point) + uniforms.row3.w); | |
| } | |
| vertex PageVertexOut page_vertex(PageVertexIn in [[stage_in]], | |
| constant SceneUniforms &uniforms [[buffer(1)]]) | |
| { | |
| PageVertexOut out; | |
| out.position = clipPosition(in.position, uniforms); | |
| out.texCoord = in.texCoord; | |
| return out; | |
| } | |
| fragment float4 page_background_fragment(PageVertexOut in [[stage_in]], | |
| constant SceneUniforms &uniforms [[buffer(1)]]) | |
| { | |
| (void)in; | |
| return uniforms.paperColor; | |
| } | |
| vertex GlyphVertexOut slug_live_vertex(GlyphVertexIn in [[stage_in]], | |
| constant SceneUniforms &uniforms [[buffer(1)]]) | |
| { | |
| GlyphVertexOut out; | |
| float2 dilatedPosition; | |
| out.renderCoord = slugDilate(in.posNorm, in.sampleCoord, in.jacobian, uniforms, dilatedPosition); | |
| out.position = clipPosition(float3(dilatedPosition, 0.0f), uniforms); | |
| out.banding = in.banding; | |
| out.glyph = in.glyph; | |
| return out; | |
| } | |
| fragment float4 slug_live_fragment(GlyphVertexOut in [[stage_in]], | |
| constant SceneUniforms &uniforms [[buffer(1)]], | |
| const device LiveCurveData *curves [[buffer(2)]], | |
| const device BandHeader *hHeaders [[buffer(3)]], | |
| const device uint *hIndices [[buffer(4)]], | |
| const device BandHeader *vHeaders [[buffer(5)]], | |
| const device uint *vIndices [[buffer(6)]]) | |
| { | |
| float2 emsPerPixel = fwidth(in.renderCoord); | |
| float2 pixelsPerEm = 1.0f / max(emsPerPixel, float2(1.0e-5f)); | |
| float2 earlyOutThreshold = in.renderCoord - emsPerPixel * 0.5f; | |
| int hBandCount = max(int(in.glyph.z), 1); | |
| int vBandCount = max(int(in.glyph.w), 1); | |
| int hBand = clamp(int(in.renderCoord.y * in.banding.y + in.banding.w), 0, hBandCount - 1); | |
| int vBand = clamp(int(in.renderCoord.x * in.banding.x + in.banding.z), 0, vBandCount - 1); | |
| float xcov = 0.0f; | |
| float xwgt = 0.0f; | |
| BandHeader hHeader = hHeaders[in.glyph.x + uint(hBand)]; | |
| bool hNegative = in.renderCoord.x < hHeader.splitCoord; | |
| uint hOffset = hNegative ? hHeader.negOffset : hHeader.offset; | |
| for (uint i = 0; i < hHeader.count; ++i) | |
| { | |
| LiveCurveData curve = curves[hIndices[hOffset + i]]; | |
| float curveMinX = float(curve.minX); | |
| float curveMaxX = float(curve.maxX); | |
| if ((!hNegative && curveMaxX < earlyOutThreshold.x) || | |
| (hNegative && curveMinX > (in.renderCoord.x + emsPerPixel.x * 0.5f))) | |
| { | |
| break; | |
| } | |
| float2 a = float2(curve.a); | |
| float2 b = float2(curve.b); | |
| float2 p1 = float2(curve.p1) - in.renderCoord; | |
| float y2 = p1.y - b.y; | |
| float y3 = a.y + p1.y - b.y * 2.0f; | |
| uint code = calcRootCode(p1.y, y2, y3); | |
| if (code != 0u) | |
| { | |
| float2 roots = solveHorizPoly(p1, a, b) * pixelsPerEm.x; | |
| if ((code & 1u) != 0u) | |
| { | |
| xcov += hNegative ? -saturate1(0.5f - roots.x) : positiveCoverage(roots.x); | |
| xwgt = max(xwgt, saturate1(1.0f - fabs(roots.x) * 2.0f)); | |
| } | |
| if (code > 1u) | |
| { | |
| xcov += hNegative ? saturate1(0.5f - roots.y) : -positiveCoverage(roots.y); | |
| xwgt = max(xwgt, saturate1(1.0f - fabs(roots.y) * 2.0f)); | |
| } | |
| } | |
| } | |
| float ycov = 0.0f; | |
| float ywgt = 0.0f; | |
| BandHeader vHeader = vHeaders[in.glyph.y + uint(vBand)]; | |
| bool vNegative = in.renderCoord.y < vHeader.splitCoord; | |
| uint vOffset = vNegative ? vHeader.negOffset : vHeader.offset; | |
| for (uint i = 0; i < vHeader.count; ++i) | |
| { | |
| LiveCurveData curve = curves[vIndices[vOffset + i]]; | |
| float curveMinY = float(curve.minY); | |
| float curveMaxY = float(curve.maxY); | |
| if ((!vNegative && curveMaxY < earlyOutThreshold.y) || | |
| (vNegative && curveMinY > (in.renderCoord.y + emsPerPixel.y * 0.5f))) | |
| { | |
| break; | |
| } | |
| float2 a = float2(curve.a); | |
| float2 b = float2(curve.b); | |
| float2 p1 = float2(curve.p1) - in.renderCoord; | |
| float x2 = p1.x - b.x; | |
| float x3 = a.x + p1.x - b.x * 2.0f; | |
| uint code = calcRootCode(p1.x, x2, x3); | |
| if (code != 0u) | |
| { | |
| float2 roots = solveVertPoly(p1, a, b) * pixelsPerEm.y; | |
| if ((code & 1u) != 0u) | |
| { | |
| ycov += vNegative ? saturate1(0.5f - roots.x) : -positiveCoverage(roots.x); | |
| ywgt = max(ywgt, saturate1(1.0f - fabs(roots.x) * 2.0f)); | |
| } | |
| if (code > 1u) | |
| { | |
| ycov += vNegative ? -saturate1(0.5f - roots.y) : positiveCoverage(roots.y); | |
| ywgt = max(ywgt, saturate1(1.0f - fabs(roots.y) * 2.0f)); | |
| } | |
| } | |
| } | |
| float coverage = calcCoverage(xcov, ycov, xwgt, ywgt); | |
| return float4(uniforms.inkColor.rgb, coverage * uniforms.inkColor.a); | |
| } | |
| )METAL"; | |
| template <typename T> | |
| static id<MTLBuffer> createSharedBuffer(id<MTLDevice> device, const std::vector<T> &items) | |
| { | |
| size_t bytes = items.size() * sizeof(T); | |
| if (bytes == 0) | |
| { | |
| return [device newBufferWithLength:sizeof(T) options:MTLResourceStorageModeShared]; | |
| } | |
| return [device newBufferWithBytes:items.data() length:bytes options:MTLResourceStorageModeShared]; | |
| } | |
| template <typename T> | |
| static id<MTLBuffer> createPrivateBuffer(id<MTLDevice> device, | |
| id<MTLCommandQueue> queue, | |
| const std::vector<T> &items) | |
| { | |
| const uint8_t zero = 0; | |
| const void *bytes = items.empty() ? static_cast<const void *>(&zero) : static_cast<const void *>(items.data()); | |
| size_t length = std::max<size_t>(items.size() * sizeof(T), 1); | |
| id<MTLBuffer> staging = [device newBufferWithBytes:bytes length:length options:MTLResourceStorageModeShared]; | |
| id<MTLBuffer> buffer = [device newBufferWithLength:length options:MTLResourceStorageModePrivate]; | |
| id<MTLCommandBuffer> commandBuffer = [queue commandBuffer]; | |
| id<MTLBlitCommandEncoder> blit = [commandBuffer blitCommandEncoder]; | |
| [blit copyFromBuffer:staging sourceOffset:0 toBuffer:buffer destinationOffset:0 size:length]; | |
| [blit endEncoding]; | |
| [commandBuffer commit]; | |
| [commandBuffer waitUntilCompleted]; | |
| return buffer; | |
| } | |
| static simd_float4x4 MatrixPerspective(float fovyRadians, float aspect, float nearZ, float farZ) | |
| { | |
| float yScale = 1.0f / std::tan(fovyRadians * 0.5f); | |
| float xScale = yScale / aspect; | |
| float zRange = farZ - nearZ; | |
| float zScale = -(farZ + nearZ) / zRange; | |
| float wzScale = -2.0f * farZ * nearZ / zRange; | |
| return simd_matrix( | |
| simd_make_float4(xScale, 0.0f, 0.0f, 0.0f), | |
| simd_make_float4(0.0f, yScale, 0.0f, 0.0f), | |
| simd_make_float4(0.0f, 0.0f, zScale, -1.0f), | |
| simd_make_float4(0.0f, 0.0f, wzScale, 0.0f)); | |
| } | |
| static simd_float4x4 MatrixLookAt(simd_float3 eye, simd_float3 target, simd_float3 up) | |
| { | |
| simd_float3 zAxis = simd_normalize(eye - target); | |
| simd_float3 xAxis = simd_normalize(simd_cross(up, zAxis)); | |
| simd_float3 yAxis = simd_cross(zAxis, xAxis); | |
| return simd_matrix( | |
| simd_make_float4(xAxis.x, yAxis.x, zAxis.x, 0.0f), | |
| simd_make_float4(xAxis.y, yAxis.y, zAxis.y, 0.0f), | |
| simd_make_float4(xAxis.z, yAxis.z, zAxis.z, 0.0f), | |
| simd_make_float4(-simd_dot(xAxis, eye), -simd_dot(yAxis, eye), -simd_dot(zAxis, eye), 1.0f)); | |
| } | |
| static fs::path locateProjectRoot() | |
| { | |
| fs::path path = fs::current_path(); | |
| while (true) | |
| { | |
| if (fs::exists(path / "vendor" / "kb" / "kb_text_shape.h")) | |
| { | |
| return path; | |
| } | |
| if (path == path.root_path()) | |
| { | |
| break; | |
| } | |
| path = path.parent_path(); | |
| } | |
| throw std::runtime_error("Could not locate project root containing vendor/kb/kb_text_shape.h"); | |
| } | |
| static std::vector<fs::path> discoverFonts(const fs::path &root) | |
| { | |
| std::vector<fs::path> fonts; | |
| for (const auto &entry : fs::directory_iterator(root)) | |
| { | |
| if (entry.is_regular_file() && entry.path().extension() == ".ttf") | |
| { | |
| fonts.push_back(entry.path()); | |
| } | |
| } | |
| std::sort(fonts.begin(), fonts.end()); | |
| return fonts; | |
| } | |
| static void destroyFonts(std::vector<FontResource> &fonts) | |
| { | |
| for (auto &font : fonts) | |
| { | |
| if (font.shapeContext != nullptr) | |
| { | |
| kbts_DestroyShapeContext(font.shapeContext); | |
| font.shapeContext = nullptr; | |
| } | |
| if (font.face != nullptr) | |
| { | |
| FT_Done_Face(font.face); | |
| font.face = nullptr; | |
| } | |
| } | |
| } | |
| static std::vector<FontResource> loadFonts(FT_Library library, const std::vector<fs::path> &fontPaths, uint32_t sizePixels) | |
| { | |
| std::vector<FontResource> fonts; | |
| fonts.reserve(fontPaths.size()); | |
| try | |
| { | |
| for (size_t index = 0; index < fontPaths.size(); ++index) | |
| { | |
| FT_Face face {}; | |
| if (FT_New_Face(library, fontPaths[index].c_str(), 0, &face) != 0) | |
| { | |
| throw std::runtime_error("Failed to load FreeType face: " + fontPaths[index].filename().string()); | |
| } | |
| if (FT_Set_Pixel_Sizes(face, 0, sizePixels) != 0) | |
| { | |
| FT_Done_Face(face); | |
| throw std::runtime_error("Failed to size face: " + fontPaths[index].filename().string()); | |
| } | |
| kbts_shape_context *shapeContext = kbts_CreateShapeContext(nullptr, nullptr); | |
| if (shapeContext == nullptr) | |
| { | |
| FT_Done_Face(face); | |
| throw std::runtime_error("Failed to create kb_text_shape context"); | |
| } | |
| kbts_font *shapeFont = kbts_ShapePushFontFromFile(shapeContext, fontPaths[index].c_str(), 0); | |
| if (shapeFont == nullptr) | |
| { | |
| kbts_DestroyShapeContext(shapeContext); | |
| FT_Done_Face(face); | |
| throw std::runtime_error("Failed to parse shaping font: " + fontPaths[index].filename().string()); | |
| } | |
| FontResource resource; | |
| resource.index = index; | |
| resource.path = fontPaths[index]; | |
| resource.face = face; | |
| resource.shapeContext = shapeContext; | |
| resource.shapeFont = shapeFont; | |
| resource.fontInfo.Base.Size = sizeof(resource.fontInfo); | |
| kbts_GetFontInfo2(shapeFont, &resource.fontInfo.Base); | |
| float unitsPerEm = std::max(1, int(resource.fontInfo.UnitsPerEm)); | |
| resource.pixelsPerUnit = static_cast<float>(sizePixels) / unitsPerEm; | |
| resource.ascentPx = resource.fontInfo.Ascent * resource.pixelsPerUnit; | |
| resource.descentPx = -resource.fontInfo.Descent * resource.pixelsPerUnit; | |
| resource.lineGapPx = resource.fontInfo.LineGap * resource.pixelsPerUnit; | |
| fonts.push_back(resource); | |
| } | |
| } | |
| catch (...) | |
| { | |
| destroyFonts(fonts); | |
| throw; | |
| } | |
| return fonts; | |
| } | |
| static std::vector<StyledSpan> buildParagraphSpans(const std::vector<FontResource> &fonts) | |
| { | |
| if (fonts.empty()) | |
| { | |
| return {}; | |
| } | |
| auto pick = [&](size_t index) { | |
| return index % fonts.size(); | |
| }; | |
| return { | |
| { pick(0), "Slug now renders a real paragraph directly on the GPU, " }, | |
| { pick(1), "with kb_text_shape handling the shaped runs, " }, | |
| { pick(2), "the cached quadratic outlines feeding the live fragment path, " }, | |
| { pick(3), "and the whole page orbiting as a single camera-facing surface.\n" }, | |
| { pick(0), "Drag to rotate, scroll to zoom, and use the minimal single-file renderer as a starting point for further experiments." } | |
| }; | |
| } | |
| static std::vector<TextToken> tokenizeSpans(const std::vector<StyledSpan> &spans) | |
| { | |
| std::vector<TextToken> tokens; | |
| for (const auto &span : spans) | |
| { | |
| size_t index = 0; | |
| while (index < span.text.size()) | |
| { | |
| unsigned char ch = static_cast<unsigned char>(span.text[index]); | |
| if (ch == '\n') | |
| { | |
| tokens.push_back(TextToken { span.fontIndex, "\n", false, true }); | |
| ++index; | |
| continue; | |
| } | |
| bool whitespace = std::isspace(ch) != 0; | |
| size_t start = index; | |
| while (index < span.text.size()) | |
| { | |
| unsigned char current = static_cast<unsigned char>(span.text[index]); | |
| if (current == '\n') | |
| { | |
| break; | |
| } | |
| if ((std::isspace(current) != 0) != whitespace) | |
| { | |
| break; | |
| } | |
| ++index; | |
| } | |
| tokens.push_back(TextToken { | |
| span.fontIndex, | |
| span.text.substr(start, index - start), | |
| whitespace, | |
| false | |
| }); | |
| } | |
| } | |
| return tokens; | |
| } | |
| static ShapedToken shapeToken(const FontResource &font, const TextToken &token) | |
| { | |
| ShapedToken shaped; | |
| shaped.fontIndex = token.fontIndex; | |
| shaped.text = token.text; | |
| shaped.whitespace = token.whitespace; | |
| shaped.newline = token.newline; | |
| if (token.newline) | |
| { | |
| return shaped; | |
| } | |
| kbts_ShapeBegin(font.shapeContext, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW); | |
| kbts_ShapeUtf8(font.shapeContext, token.text.c_str(), static_cast<int>(token.text.size()), KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX); | |
| kbts_ShapeEnd(font.shapeContext); | |
| if (kbts_ShapeError(font.shapeContext) != KBTS_SHAPE_ERROR_NONE) | |
| { | |
| throw std::runtime_error("kb_text_shape failed on token: " + token.text); | |
| } | |
| int cursorX = 0; | |
| int cursorY = 0; | |
| kbts_run run {}; | |
| while (kbts_ShapeRun(font.shapeContext, &run)) | |
| { | |
| kbts_glyph *glyph = nullptr; | |
| while (kbts_GlyphIteratorNext(&run.Glyphs, &glyph)) | |
| { | |
| shaped.glyphs.push_back(GlyphPlacement { | |
| font.index, | |
| glyph->Id, | |
| (cursorX + glyph->OffsetX) * font.pixelsPerUnit, | |
| (cursorY + glyph->OffsetY) * font.pixelsPerUnit | |
| }); | |
| cursorX += glyph->AdvanceX; | |
| cursorY += glyph->AdvanceY; | |
| } | |
| } | |
| shaped.advanceX = cursorX * font.pixelsPerUnit; | |
| return shaped; | |
| } | |
| static ParagraphLayout layoutParagraph(const std::vector<FontResource> &fonts, | |
| const std::vector<ShapedToken> &tokens, | |
| uint32_t paragraphWidth) | |
| { | |
| ParagraphLayout layout; | |
| layout.contentWidth = static_cast<float>(paragraphWidth); | |
| for (const auto &font : fonts) | |
| { | |
| layout.maxAscent = std::max(layout.maxAscent, font.ascentPx); | |
| layout.maxDescent = std::max(layout.maxDescent, font.descentPx); | |
| layout.lineAdvance = std::max(layout.lineAdvance, font.ascentPx + font.descentPx + font.lineGapPx); | |
| } | |
| layout.lineAdvance = std::max(layout.lineAdvance, layout.maxAscent + layout.maxDescent + 6.0f); | |
| layout.canvasWidth = layout.contentWidth + kParagraphPadding * 2.0f; | |
| float cursorX = kParagraphPadding; | |
| float baselineY = kParagraphPadding + layout.maxAscent; | |
| layout.lineCount = 1; | |
| for (const auto &token : tokens) | |
| { | |
| if (token.newline) | |
| { | |
| cursorX = kParagraphPadding; | |
| baselineY += layout.lineAdvance; | |
| ++layout.lineCount; | |
| continue; | |
| } | |
| if (token.whitespace && std::abs(cursorX - kParagraphPadding) < 0.01f) | |
| { | |
| continue; | |
| } | |
| float lineLimit = kParagraphPadding + layout.contentWidth; | |
| if (!token.whitespace && cursorX > kParagraphPadding && cursorX + token.advanceX > lineLimit) | |
| { | |
| cursorX = kParagraphPadding; | |
| baselineY += layout.lineAdvance; | |
| ++layout.lineCount; | |
| } | |
| for (const auto &glyph : token.glyphs) | |
| { | |
| layout.glyphs.push_back(PositionedGlyph { | |
| glyph.fontIndex, | |
| glyph.glyphIndex, | |
| cursorX + glyph.offsetX, | |
| baselineY - glyph.offsetY | |
| }); | |
| } | |
| cursorX += token.advanceX; | |
| } | |
| layout.canvasHeight = baselineY + layout.maxDescent + kParagraphPadding; | |
| return layout; | |
| } | |
| static std::array<float, 2> toPoint(FT_Vector point) | |
| { | |
| return { static_cast<float>(point.x) / 64.0f, static_cast<float>(point.y) / 64.0f }; | |
| } | |
| static QuadraticCurve makeQuadratic(const std::array<float, 2> &p1, | |
| const std::array<float, 2> &p2, | |
| const std::array<float, 2> &p3) | |
| { | |
| QuadraticCurve curve; | |
| curve.p1 = p1; | |
| curve.p2 = p2; | |
| curve.p3 = p3; | |
| curve.minX = std::min({ p1[0], p2[0], p3[0] }); | |
| curve.minY = std::min({ p1[1], p2[1], p3[1] }); | |
| curve.maxX = std::max({ p1[0], p2[0], p3[0] }); | |
| curve.maxY = std::max({ p1[1], p2[1], p3[1] }); | |
| return curve; | |
| } | |
| class OutlineCollector | |
| { | |
| public: | |
| explicit OutlineCollector(std::vector<QuadraticCurve> &curves) : curves_(curves) {} | |
| static int moveTo(const FT_Vector *to, void *user) | |
| { | |
| auto *self = static_cast<OutlineCollector *>(user); | |
| self->current_ = toPoint(*to); | |
| return 0; | |
| } | |
| static int lineTo(const FT_Vector *to, void *user) | |
| { | |
| auto *self = static_cast<OutlineCollector *>(user); | |
| std::array<float, 2> target = toPoint(*to); | |
| std::array<float, 2> control = { | |
| (self->current_[0] + target[0]) * 0.5f, | |
| (self->current_[1] + target[1]) * 0.5f | |
| }; | |
| self->curves_.push_back(makeQuadratic(self->current_, control, target)); | |
| self->current_ = target; | |
| return 0; | |
| } | |
| static int conicTo(const FT_Vector *control, const FT_Vector *to, void *user) | |
| { | |
| auto *self = static_cast<OutlineCollector *>(user); | |
| std::array<float, 2> q = toPoint(*control); | |
| std::array<float, 2> target = toPoint(*to); | |
| self->curves_.push_back(makeQuadratic(self->current_, q, target)); | |
| self->current_ = target; | |
| return 0; | |
| } | |
| static int cubicTo(const FT_Vector *control1, const FT_Vector *control2, const FT_Vector *to, void *user) | |
| { | |
| auto *self = static_cast<OutlineCollector *>(user); | |
| std::array<float, 2> p0 = self->current_; | |
| std::array<float, 2> p1 = toPoint(*control1); | |
| std::array<float, 2> p2 = toPoint(*control2); | |
| std::array<float, 2> p3 = toPoint(*to); | |
| constexpr int subdivisions = 8; | |
| std::array<float, 2> last = p0; | |
| for (int i = 1; i <= subdivisions; ++i) | |
| { | |
| float t = static_cast<float>(i) / static_cast<float>(subdivisions); | |
| float omt = 1.0f - t; | |
| std::array<float, 2> point = { | |
| omt * omt * omt * p0[0] + 3.0f * omt * omt * t * p1[0] + 3.0f * omt * t * t * p2[0] + t * t * t * p3[0], | |
| omt * omt * omt * p0[1] + 3.0f * omt * omt * t * p1[1] + 3.0f * omt * t * t * p2[1] + t * t * t * p3[1] | |
| }; | |
| std::array<float, 2> control = { (last[0] + point[0]) * 0.5f, (last[1] + point[1]) * 0.5f }; | |
| self->curves_.push_back(makeQuadratic(last, control, point)); | |
| last = point; | |
| } | |
| self->current_ = p3; | |
| return 0; | |
| } | |
| private: | |
| std::vector<QuadraticCurve> &curves_; | |
| std::array<float, 2> current_ { 0.0f, 0.0f }; | |
| }; | |
| struct CornerClip | |
| { | |
| bool enabled = false; | |
| float edgeX = 0.0f; | |
| float edgeY = 0.0f; | |
| float area = 0.0f; | |
| }; | |
| static CornerClip chooseCornerClip(float minX, | |
| float minY, | |
| float maxX, | |
| float maxY, | |
| const std::vector<std::array<float, 2>> &controlPoints, | |
| int signX, | |
| int signY) | |
| { | |
| static constexpr float kCandidateNormals[][2] = { | |
| { 1.0f, 1.0f }, | |
| { 2.0f, 1.0f }, | |
| { 1.0f, 2.0f }, | |
| { 3.0f, 1.0f }, | |
| { 1.0f, 3.0f }, | |
| }; | |
| constexpr float kMinClipArea = 0.5f; | |
| constexpr float kEpsilon = 1.0e-4f; | |
| CornerClip best; | |
| float cornerX = (signX > 0) ? maxX : minX; | |
| float cornerY = (signY > 0) ? maxY : minY; | |
| for (const auto &candidate : kCandidateNormals) | |
| { | |
| float nx = candidate[0] * static_cast<float>(signX); | |
| float ny = candidate[1] * static_cast<float>(signY); | |
| float support = -std::numeric_limits<float>::infinity(); | |
| for (const auto &point : controlPoints) | |
| { | |
| support = std::max(support, nx * point[0] + ny * point[1]); | |
| } | |
| float cornerSupport = nx * cornerX + ny * cornerY; | |
| if (support >= cornerSupport - kEpsilon) | |
| { | |
| continue; | |
| } | |
| float xOnHorizontal = (support - ny * cornerY) / nx; | |
| float yOnVertical = (support - nx * cornerX) / ny; | |
| if (!(xOnHorizontal > minX + kEpsilon && xOnHorizontal < maxX - kEpsilon && | |
| yOnVertical > minY + kEpsilon && yOnVertical < maxY - kEpsilon)) | |
| { | |
| continue; | |
| } | |
| bool inwardX = (signX > 0) ? (xOnHorizontal < cornerX - kEpsilon) : (xOnHorizontal > cornerX + kEpsilon); | |
| bool inwardY = (signY > 0) ? (yOnVertical < cornerY - kEpsilon) : (yOnVertical > cornerY + kEpsilon); | |
| if (!inwardX || !inwardY) | |
| { | |
| continue; | |
| } | |
| float area = 0.5f * std::abs(cornerX - xOnHorizontal) * std::abs(cornerY - yOnVertical); | |
| if (area >= kMinClipArea && area > best.area) | |
| { | |
| best.enabled = true; | |
| best.edgeX = xOnHorizontal; | |
| best.edgeY = yOnVertical; | |
| best.area = area; | |
| } | |
| } | |
| return best; | |
| } | |
| static std::vector<PolygonVertex> makeRectanglePolygon(float minX, float minY, float maxX, float maxY) | |
| { | |
| return { | |
| { { minX, minY }, { -1.0f, -1.0f } }, | |
| { { maxX, minY }, { 1.0f, -1.0f } }, | |
| { { maxX, maxY }, { 1.0f, 1.0f } }, | |
| { { minX, maxY }, { -1.0f, 1.0f } }, | |
| }; | |
| } | |
| static std::vector<PolygonVertex> buildBoundingPolygon(float minX, | |
| float minY, | |
| float maxX, | |
| float maxY, | |
| const std::vector<std::array<float, 2>> &controlPoints) | |
| { | |
| constexpr float kEpsilon = 1.0e-4f; | |
| if (controlPoints.empty()) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| CornerClip clips[4] = { | |
| chooseCornerClip(minX, minY, maxX, maxY, controlPoints, -1, -1), | |
| chooseCornerClip(minX, minY, maxX, maxY, controlPoints, 1, -1), | |
| chooseCornerClip(minX, minY, maxX, maxY, controlPoints, 1, 1), | |
| chooseCornerClip(minX, minY, maxX, maxY, controlPoints, -1, 1), | |
| }; | |
| auto disableSmallerClip = [&](int lhs, int rhs) { | |
| if (!clips[lhs].enabled || !clips[rhs].enabled) | |
| { | |
| return; | |
| } | |
| if (clips[lhs].area <= clips[rhs].area) | |
| { | |
| clips[lhs].enabled = false; | |
| } | |
| else | |
| { | |
| clips[rhs].enabled = false; | |
| } | |
| }; | |
| if (clips[0].enabled && clips[1].enabled && !(clips[0].edgeX < clips[1].edgeX - kEpsilon)) | |
| { | |
| disableSmallerClip(0, 1); | |
| } | |
| if (clips[3].enabled && clips[2].enabled && !(clips[3].edgeX < clips[2].edgeX - kEpsilon)) | |
| { | |
| disableSmallerClip(3, 2); | |
| } | |
| if (clips[0].enabled && clips[3].enabled && !(clips[0].edgeY < clips[3].edgeY - kEpsilon)) | |
| { | |
| disableSmallerClip(0, 3); | |
| } | |
| if (clips[1].enabled && clips[2].enabled && !(clips[1].edgeY < clips[2].edgeY - kEpsilon)) | |
| { | |
| disableSmallerClip(1, 2); | |
| } | |
| struct Point2 | |
| { | |
| float x; | |
| float y; | |
| }; | |
| std::vector<Point2> points; | |
| points.reserve(8); | |
| auto appendPoint = [&](float x, float y) { | |
| if (!points.empty()) | |
| { | |
| const Point2 &last = points.back(); | |
| if (std::abs(last.x - x) < kEpsilon && std::abs(last.y - y) < kEpsilon) | |
| { | |
| return; | |
| } | |
| } | |
| points.push_back(Point2 { x, y }); | |
| }; | |
| appendPoint(clips[0].enabled ? clips[0].edgeX : minX, minY); | |
| appendPoint(clips[1].enabled ? clips[1].edgeX : maxX, minY); | |
| if (clips[1].enabled) | |
| { | |
| appendPoint(maxX, clips[1].edgeY); | |
| } | |
| appendPoint(maxX, clips[2].enabled ? clips[2].edgeY : maxY); | |
| if (clips[2].enabled) | |
| { | |
| appendPoint(clips[2].edgeX, maxY); | |
| } | |
| appendPoint(clips[3].enabled ? clips[3].edgeX : minX, maxY); | |
| if (clips[3].enabled) | |
| { | |
| appendPoint(minX, clips[3].edgeY); | |
| } | |
| if (clips[0].enabled) | |
| { | |
| appendPoint(minX, clips[0].edgeY); | |
| } | |
| if (points.size() < 3) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| float signedArea = 0.0f; | |
| for (size_t i = 0; i < points.size(); ++i) | |
| { | |
| const Point2 &a = points[i]; | |
| const Point2 &b = points[(i + 1) % points.size()]; | |
| signedArea += a.x * b.y - b.x * a.y; | |
| } | |
| if (std::abs(signedArea) < kEpsilon) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| if (signedArea < 0.0f) | |
| { | |
| std::reverse(points.begin(), points.end()); | |
| } | |
| for (size_t i = 0; i < points.size(); ++i) | |
| { | |
| const Point2 &a = points[i]; | |
| const Point2 &b = points[(i + 1) % points.size()]; | |
| const Point2 &c = points[(i + 2) % points.size()]; | |
| float cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); | |
| if (cross <= kEpsilon) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| } | |
| std::vector<std::array<float, 2>> edgeNormals(points.size()); | |
| for (size_t i = 0; i < points.size(); ++i) | |
| { | |
| const Point2 &a = points[i]; | |
| const Point2 &b = points[(i + 1) % points.size()]; | |
| float edgeX = b.x - a.x; | |
| float edgeY = b.y - a.y; | |
| float length = std::sqrt(edgeX * edgeX + edgeY * edgeY); | |
| if (length < kEpsilon) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| edgeNormals[i] = { edgeY / length, -edgeX / length }; | |
| } | |
| std::vector<PolygonVertex> polygon; | |
| polygon.reserve(points.size()); | |
| for (size_t i = 0; i < points.size(); ++i) | |
| { | |
| const auto &prevNormal = edgeNormals[(i + points.size() - 1) % points.size()]; | |
| const auto &nextNormal = edgeNormals[i]; | |
| float det = prevNormal[0] * nextNormal[1] - prevNormal[1] * nextNormal[0]; | |
| if (det <= kEpsilon) | |
| { | |
| return makeRectanglePolygon(minX, minY, maxX, maxY); | |
| } | |
| float normalX = (nextNormal[1] - prevNormal[1]) / det; | |
| float normalY = (prevNormal[0] - nextNormal[0]) / det; | |
| polygon.push_back(PolygonVertex { | |
| { points[i].x, points[i].y }, | |
| { normalX, normalY } | |
| }); | |
| } | |
| return polygon; | |
| } | |
| static GlyphPreparation prepareGlyph(FT_Face face, uint32_t glyphIndex, uint32_t sizePixels) | |
| { | |
| if (FT_Set_Pixel_Sizes(face, 0, sizePixels) != 0) | |
| { | |
| throw std::runtime_error("FT_Set_Pixel_Sizes failed"); | |
| } | |
| if (FT_Load_Glyph(face, glyphIndex, FT_LOAD_NO_HINTING | FT_LOAD_NO_BITMAP) != 0) | |
| { | |
| throw std::runtime_error("FT_Load_Glyph failed"); | |
| } | |
| GlyphPreparation prep; | |
| FT_BBox bbox {}; | |
| if (face->glyph->format == FT_GLYPH_FORMAT_OUTLINE && face->glyph->outline.n_points > 0) | |
| { | |
| FT_Outline_Get_CBox(&face->glyph->outline, &bbox); | |
| } | |
| prep.config.glyphMinX = static_cast<float>(bbox.xMin) / 64.0f; | |
| prep.config.glyphMinY = static_cast<float>(bbox.yMin) / 64.0f; | |
| prep.config.glyphMaxX = static_cast<float>(bbox.xMax) / 64.0f; | |
| prep.config.glyphMaxY = static_cast<float>(bbox.yMax) / 64.0f; | |
| std::vector<QuadraticCurve> curves; | |
| if (face->glyph->format == FT_GLYPH_FORMAT_OUTLINE && face->glyph->outline.n_points > 0) | |
| { | |
| OutlineCollector collector(curves); | |
| FT_Outline_Funcs funcs {}; | |
| funcs.move_to = &OutlineCollector::moveTo; | |
| funcs.line_to = &OutlineCollector::lineTo; | |
| funcs.conic_to = &OutlineCollector::conicTo; | |
| funcs.cubic_to = &OutlineCollector::cubicTo; | |
| if (FT_Outline_Decompose(&face->glyph->outline, &funcs, &collector) != 0) | |
| { | |
| throw std::runtime_error("FT_Outline_Decompose failed"); | |
| } | |
| } | |
| std::vector<std::array<float, 2>> controlPoints; | |
| controlPoints.reserve(curves.size() * 3); | |
| prep.curves.reserve(curves.size()); | |
| for (const auto &curve : curves) | |
| { | |
| CurveData data {}; | |
| data.p1[0] = curve.p1[0]; | |
| data.p1[1] = curve.p1[1]; | |
| data.p2[0] = curve.p2[0]; | |
| data.p2[1] = curve.p2[1]; | |
| data.p3[0] = curve.p3[0]; | |
| data.p3[1] = curve.p3[1]; | |
| data.minX = curve.minX; | |
| data.minY = curve.minY; | |
| data.maxX = curve.maxX; | |
| data.maxY = curve.maxY; | |
| prep.curves.push_back(data); | |
| controlPoints.push_back(curve.p1); | |
| controlPoints.push_back(curve.p2); | |
| controlPoints.push_back(curve.p3); | |
| } | |
| prep.polygonVertices = buildBoundingPolygon(prep.config.glyphMinX, | |
| prep.config.glyphMinY, | |
| prep.config.glyphMaxX, | |
| prep.config.glyphMaxY, | |
| controlPoints); | |
| float width = std::max(prep.config.glyphMaxX - prep.config.glyphMinX, 1.0f); | |
| float height = std::max(prep.config.glyphMaxY - prep.config.glyphMinY, 1.0f); | |
| auto chooseBandCount = [](float dimension) { | |
| uint32_t rounded = std::max(1u, static_cast<uint32_t>(std::ceil(dimension))); | |
| if (rounded < 24u) | |
| { | |
| return std::max(1u, rounded / 2u); | |
| } | |
| return 24u; | |
| }; | |
| prep.config.hBandCount = chooseBandCount(height); | |
| prep.config.vBandCount = chooseBandCount(width); | |
| prep.config.hScale = static_cast<float>(prep.config.hBandCount) / height; | |
| prep.config.hOffset = -prep.config.glyphMinY * prep.config.hScale; | |
| prep.config.vScale = static_cast<float>(prep.config.vBandCount) / width; | |
| prep.config.vOffset = -prep.config.glyphMinX * prep.config.vScale; | |
| std::vector<std::vector<uint32_t>> hBands(prep.config.hBandCount); | |
| std::vector<std::vector<uint32_t>> vBands(prep.config.vBandCount); | |
| for (uint32_t index = 0; index < curves.size(); ++index) | |
| { | |
| const auto &curve = curves[index]; | |
| int hStart = std::clamp(static_cast<int>(std::floor((curve.minY - kBandDilation) * prep.config.hScale + prep.config.hOffset)), | |
| 0, | |
| static_cast<int>(prep.config.hBandCount) - 1); | |
| int hEnd = std::clamp(static_cast<int>(std::floor((curve.maxY + kBandDilation) * prep.config.hScale + prep.config.hOffset)), | |
| 0, | |
| static_cast<int>(prep.config.hBandCount) - 1); | |
| for (int band = hStart; band <= hEnd; ++band) | |
| { | |
| hBands[static_cast<size_t>(band)].push_back(index); | |
| } | |
| int vStart = std::clamp(static_cast<int>(std::floor((curve.minX - kBandDilation) * prep.config.vScale + prep.config.vOffset)), | |
| 0, | |
| static_cast<int>(prep.config.vBandCount) - 1); | |
| int vEnd = std::clamp(static_cast<int>(std::floor((curve.maxX + kBandDilation) * prep.config.vScale + prep.config.vOffset)), | |
| 0, | |
| static_cast<int>(prep.config.vBandCount) - 1); | |
| for (int band = vStart; band <= vEnd; ++band) | |
| { | |
| vBands[static_cast<size_t>(band)].push_back(index); | |
| } | |
| } | |
| prep.hHeaders.reserve(hBands.size()); | |
| for (auto &band : hBands) | |
| { | |
| std::sort(band.begin(), band.end(), [&](uint32_t lhs, uint32_t rhs) { | |
| return curves[lhs].maxX > curves[rhs].maxX; | |
| }); | |
| prep.hHeaders.push_back(BandHeader { static_cast<uint32_t>(band.size()), static_cast<uint32_t>(prep.hIndices.size()), 0u, 0.0f }); | |
| prep.hIndices.insert(prep.hIndices.end(), band.begin(), band.end()); | |
| } | |
| prep.vHeaders.reserve(vBands.size()); | |
| for (auto &band : vBands) | |
| { | |
| std::sort(band.begin(), band.end(), [&](uint32_t lhs, uint32_t rhs) { | |
| return curves[lhs].maxY > curves[rhs].maxY; | |
| }); | |
| prep.vHeaders.push_back(BandHeader { static_cast<uint32_t>(band.size()), static_cast<uint32_t>(prep.vIndices.size()), 0u, 0.0f }); | |
| prep.vIndices.insert(prep.vIndices.end(), band.begin(), band.end()); | |
| } | |
| return prep; | |
| } | |
| static LiveFrameGlyphInfo appendFrameGlyphData(const GlyphPreparation &prep, | |
| std::vector<LiveCurveData> &curves, | |
| std::vector<BandHeader> &hHeaders, | |
| std::vector<uint32_t> &hIndices, | |
| std::vector<BandHeader> &vHeaders, | |
| std::vector<uint32_t> &vIndices) | |
| { | |
| LiveFrameGlyphInfo info; | |
| info.prep = const_cast<GlyphPreparation *>(&prep); | |
| info.hHeaderBase = static_cast<uint32_t>(hHeaders.size()); | |
| info.vHeaderBase = static_cast<uint32_t>(vHeaders.size()); | |
| uint32_t curveBase = static_cast<uint32_t>(curves.size()); | |
| curves.reserve(curves.size() + prep.curves.size()); | |
| for (const CurveData &curve : prep.curves) | |
| { | |
| LiveCurveData live {}; | |
| live.p1[0] = curve.p1[0]; | |
| live.p1[1] = curve.p1[1]; | |
| live.a[0] = static_cast<__fp16>(curve.p1[0] - curve.p2[0] * 2.0f + curve.p3[0]); | |
| live.a[1] = static_cast<__fp16>(curve.p1[1] - curve.p2[1] * 2.0f + curve.p3[1]); | |
| live.b[0] = static_cast<__fp16>(curve.p1[0] - curve.p2[0]); | |
| live.b[1] = static_cast<__fp16>(curve.p1[1] - curve.p2[1]); | |
| live.minX = static_cast<__fp16>(curve.minX); | |
| live.minY = static_cast<__fp16>(curve.minY); | |
| live.maxX = static_cast<__fp16>(curve.maxX); | |
| live.maxY = static_cast<__fp16>(curve.maxY); | |
| curves.push_back(live); | |
| } | |
| auto bestSplitCoord = [&](const std::vector<uint32_t> &indices, bool horizontal) { | |
| if (indices.empty()) | |
| { | |
| return 0.0f; | |
| } | |
| std::vector<float> mins; | |
| std::vector<float> maxs; | |
| mins.reserve(indices.size()); | |
| maxs.reserve(indices.size()); | |
| for (uint32_t index : indices) | |
| { | |
| const LiveCurveData &curve = curves[curveBase + index]; | |
| mins.push_back(horizontal ? curve.minX : curve.minY); | |
| maxs.push_back(horizontal ? curve.maxX : curve.maxY); | |
| } | |
| std::sort(mins.begin(), mins.end()); | |
| std::sort(maxs.begin(), maxs.end()); | |
| size_t minCursor = 0; | |
| size_t maxCursor = 0; | |
| uint32_t negCount = 0; | |
| uint32_t posCount = static_cast<uint32_t>(indices.size()); | |
| uint32_t bestCost = posCount; | |
| float bestCoord = (mins.front() + maxs.back()) * 0.5f; | |
| std::vector<float> events; | |
| events.reserve(mins.size() + maxs.size()); | |
| events.insert(events.end(), mins.begin(), mins.end()); | |
| events.insert(events.end(), maxs.begin(), maxs.end()); | |
| std::sort(events.begin(), events.end()); | |
| for (float coord : events) | |
| { | |
| while (minCursor < mins.size() && mins[minCursor] <= coord) | |
| { | |
| ++negCount; | |
| ++minCursor; | |
| } | |
| while (maxCursor < maxs.size() && maxs[maxCursor] < coord) | |
| { | |
| --posCount; | |
| ++maxCursor; | |
| } | |
| uint32_t cost = std::max(negCount, posCount); | |
| if (cost < bestCost) | |
| { | |
| bestCost = cost; | |
| bestCoord = coord; | |
| } | |
| } | |
| return bestCoord; | |
| }; | |
| for (const BandHeader &header : prep.hHeaders) | |
| { | |
| std::vector<uint32_t> positive; | |
| positive.reserve(header.count); | |
| for (uint32_t i = 0; i < header.count; ++i) | |
| { | |
| positive.push_back(prep.hIndices[header.offset + i]); | |
| } | |
| std::vector<uint32_t> negative = positive; | |
| std::sort(negative.begin(), negative.end(), [&](uint32_t lhs, uint32_t rhs) { | |
| return curves[curveBase + lhs].minX < curves[curveBase + rhs].minX; | |
| }); | |
| float splitCoord = bestSplitCoord(positive, true); | |
| uint32_t posOffset = static_cast<uint32_t>(hIndices.size()); | |
| for (uint32_t index : positive) | |
| { | |
| hIndices.push_back(curveBase + index); | |
| } | |
| uint32_t negOffset = static_cast<uint32_t>(hIndices.size()); | |
| for (uint32_t index : negative) | |
| { | |
| hIndices.push_back(curveBase + index); | |
| } | |
| hHeaders.push_back(BandHeader { header.count, posOffset, negOffset, splitCoord }); | |
| } | |
| for (const BandHeader &header : prep.vHeaders) | |
| { | |
| std::vector<uint32_t> positive; | |
| positive.reserve(header.count); | |
| for (uint32_t i = 0; i < header.count; ++i) | |
| { | |
| positive.push_back(prep.vIndices[header.offset + i]); | |
| } | |
| std::vector<uint32_t> negative = positive; | |
| std::sort(negative.begin(), negative.end(), [&](uint32_t lhs, uint32_t rhs) { | |
| return curves[curveBase + lhs].minY < curves[curveBase + rhs].minY; | |
| }); | |
| float splitCoord = bestSplitCoord(positive, false); | |
| uint32_t posOffset = static_cast<uint32_t>(vIndices.size()); | |
| for (uint32_t index : positive) | |
| { | |
| vIndices.push_back(curveBase + index); | |
| } | |
| uint32_t negOffset = static_cast<uint32_t>(vIndices.size()); | |
| for (uint32_t index : negative) | |
| { | |
| vIndices.push_back(curveBase + index); | |
| } | |
| vHeaders.push_back(BandHeader { header.count, posOffset, negOffset, splitCoord }); | |
| } | |
| return info; | |
| } | |
| template <typename PointTransform, typename NormalTransform> | |
| static void appendGlyphPolygonVertices(const GlyphPreparation &prep, | |
| const LiveFrameGlyphInfo &info, | |
| const simd_float4 &jacobian, | |
| PointTransform pointTransform, | |
| NormalTransform normalTransform, | |
| std::vector<LiveGlyphVertex> &outVertices) | |
| { | |
| if (prep.polygonVertices.size() < 3) | |
| { | |
| return; | |
| } | |
| auto appendVertex = [&](const PolygonVertex &vertex) { | |
| simd_float2 position = pointTransform(vertex); | |
| simd_float2 normal = normalTransform(vertex); | |
| outVertices.push_back(LiveGlyphVertex { | |
| { position.x, position.y, normal.x, normal.y }, | |
| { vertex.position[0], vertex.position[1] }, | |
| jacobian, | |
| { info.prep->config.vScale, info.prep->config.hScale, info.prep->config.vOffset, info.prep->config.hOffset }, | |
| { info.hHeaderBase, info.vHeaderBase, info.prep->config.hBandCount, info.prep->config.vBandCount } | |
| }); | |
| }; | |
| const PolygonVertex &base = prep.polygonVertices.front(); | |
| for (size_t i = 1; i + 1 < prep.polygonVertices.size(); ++i) | |
| { | |
| appendVertex(base); | |
| appendVertex(prep.polygonVertices[i]); | |
| appendVertex(prep.polygonVertices[i + 1]); | |
| } | |
| } | |
| class LiveParagraphRenderer | |
| { | |
| public: | |
| LiveParagraphRenderer(const std::vector<fs::path> &fontPaths, uint32_t sizePixels, uint32_t paragraphWidth) | |
| : sizePixels_(sizePixels), paragraphWidth_(paragraphWidth) | |
| { | |
| if (FT_Init_FreeType(&ftLibrary_) != 0) | |
| { | |
| throw std::runtime_error("FT_Init_FreeType failed"); | |
| } | |
| fonts_ = loadFonts(ftLibrary_, fontPaths, sizePixels_); | |
| spans_ = buildParagraphSpans(fonts_); | |
| tokens_ = tokenizeSpans(spans_); | |
| } | |
| ~LiveParagraphRenderer() | |
| { | |
| destroyFonts(fonts_); | |
| if (ftLibrary_ != nullptr) | |
| { | |
| FT_Done_FreeType(ftLibrary_); | |
| ftLibrary_ = nullptr; | |
| } | |
| } | |
| LiveFrame buildFrame() | |
| { | |
| LiveFrame frame; | |
| auto shapeStart = std::chrono::steady_clock::now(); | |
| std::vector<ShapedToken> shapedTokens; | |
| shapedTokens.reserve(tokens_.size()); | |
| for (const auto &token : tokens_) | |
| { | |
| shapedTokens.push_back(shapeToken(fonts_[token.fontIndex], token)); | |
| } | |
| auto shapeEnd = std::chrono::steady_clock::now(); | |
| ParagraphLayout layout = layoutParagraph(fonts_, shapedTokens, paragraphWidth_); | |
| frame.shapeMs = std::chrono::duration<double, std::milli>(shapeEnd - shapeStart).count(); | |
| frame.glyphCount = layout.glyphs.size(); | |
| float pageWidth = layout.canvasWidth; | |
| float pageHeight = layout.canvasHeight; | |
| frame.pageAspect = pageWidth / std::max(pageHeight, 1.0f); | |
| float sceneScale = 2.4f / std::max(pageWidth, pageHeight); | |
| float inverseScale = 1.0f / sceneScale; | |
| float centerX = pageWidth * 0.5f; | |
| float centerY = pageHeight * 0.5f; | |
| auto makeWorldX = [&](float pageX) { | |
| return (pageX - centerX) * sceneScale; | |
| }; | |
| auto makeWorldY = [&](float pageY) { | |
| return (centerY - pageY) * sceneScale; | |
| }; | |
| float left = makeWorldX(0.0f); | |
| float right = makeWorldX(pageWidth); | |
| float top = makeWorldY(0.0f); | |
| float bottom = makeWorldY(pageHeight); | |
| constexpr float kPageDepth = -0.02f; | |
| frame.pageVertices = { | |
| { { left, bottom, kPageDepth }, { 0.0f, 1.0f } }, | |
| { { right, bottom, kPageDepth }, { 1.0f, 1.0f } }, | |
| { { left, top, kPageDepth }, { 0.0f, 0.0f } }, | |
| { { left, top, kPageDepth }, { 0.0f, 0.0f } }, | |
| { { right, bottom, kPageDepth }, { 1.0f, 1.0f } }, | |
| { { right, top, kPageDepth }, { 1.0f, 0.0f } }, | |
| }; | |
| std::map<GlyphKey, LiveFrameGlyphInfo> frameGlyphs; | |
| const simd_float4 jacobian = { inverseScale, 0.0f, 0.0f, -inverseScale }; | |
| auto prepStart = std::chrono::steady_clock::now(); | |
| for (const auto &placed : layout.glyphs) | |
| { | |
| GlyphKey key { placed.fontIndex, placed.glyphIndex }; | |
| auto found = frameGlyphs.find(key); | |
| if (found == frameGlyphs.end()) | |
| { | |
| GlyphPreparation &prep = ensureGlyphPrep(placed.fontIndex, placed.glyphIndex); | |
| found = frameGlyphs.emplace(key, | |
| appendFrameGlyphData(prep, | |
| frame.curves, | |
| frame.hHeaders, | |
| frame.hIndices, | |
| frame.vHeaders, | |
| frame.vIndices)).first; | |
| } | |
| const LiveFrameGlyphInfo &info = found->second; | |
| const GlyphPreparation &prep = *info.prep; | |
| if (prep.curves.empty()) | |
| { | |
| continue; | |
| } | |
| appendGlyphPolygonVertices(prep, | |
| info, | |
| jacobian, | |
| [&](const PolygonVertex &vertex) { | |
| float pageX = placed.originX + vertex.position[0]; | |
| float pageY = placed.originY - vertex.position[1]; | |
| return simd_make_float2(makeWorldX(pageX), makeWorldY(pageY)); | |
| }, | |
| [&](const PolygonVertex &vertex) { | |
| return simd_make_float2(vertex.normal[0] * sceneScale, -vertex.normal[1] * sceneScale); | |
| }, | |
| frame.glyphVertices); | |
| } | |
| auto prepEnd = std::chrono::steady_clock::now(); | |
| frame.prepMs = std::chrono::duration<double, std::milli>(prepEnd - prepStart).count(); | |
| frame.uniqueGlyphCount = frameGlyphs.size(); | |
| return frame; | |
| } | |
| private: | |
| GlyphPreparation &ensureGlyphPrep(size_t fontIndex, uint32_t glyphIndex) | |
| { | |
| GlyphKey key { fontIndex, glyphIndex }; | |
| auto found = glyphPrepCache_.find(key); | |
| if (found == glyphPrepCache_.end()) | |
| { | |
| found = glyphPrepCache_.emplace(key, prepareGlyph(fonts_[fontIndex].face, glyphIndex, sizePixels_)).first; | |
| } | |
| return found->second; | |
| } | |
| FT_Library ftLibrary_ = nullptr; | |
| std::vector<FontResource> fonts_; | |
| std::vector<StyledSpan> spans_; | |
| std::vector<TextToken> tokens_; | |
| std::map<GlyphKey, GlyphPreparation> glyphPrepCache_; | |
| uint32_t sizePixels_ = 0; | |
| uint32_t paragraphWidth_ = 0; | |
| }; | |
| class HudTextRenderer | |
| { | |
| public: | |
| HudTextRenderer(const fs::path &fontPath, uint32_t sizePixels) : sizePixels_(sizePixels) | |
| { | |
| if (FT_Init_FreeType(&ftLibrary_) != 0) | |
| { | |
| throw std::runtime_error("FT_Init_FreeType failed for HUD"); | |
| } | |
| fonts_ = loadFonts(ftLibrary_, std::vector<fs::path> { fontPath }, sizePixels_); | |
| if (fonts_.empty()) | |
| { | |
| throw std::runtime_error("Failed to load Mohave HUD font"); | |
| } | |
| } | |
| ~HudTextRenderer() | |
| { | |
| destroyFonts(fonts_); | |
| if (ftLibrary_ != nullptr) | |
| { | |
| FT_Done_FreeType(ftLibrary_); | |
| ftLibrary_ = nullptr; | |
| } | |
| } | |
| HudFrame buildFrame(std::string_view text) const | |
| { | |
| HudFrame frame; | |
| if (text.empty()) | |
| { | |
| return frame; | |
| } | |
| const FontResource &font = fonts_.front(); | |
| const float marginX = 18.0f; | |
| const float marginY = 18.0f; | |
| const float lineAdvance = std::max(font.ascentPx + font.descentPx + font.lineGapPx, static_cast<float>(sizePixels_) + 4.0f); | |
| const simd_float4 jacobian = { 1.0f, 0.0f, 0.0f, -1.0f }; | |
| std::map<uint32_t, LiveFrameGlyphInfo> frameGlyphs; | |
| size_t lineStart = 0; | |
| uint32_t lineIndex = 0; | |
| while (lineStart <= text.size()) | |
| { | |
| size_t lineEnd = text.find('\n', lineStart); | |
| if (lineEnd == std::string_view::npos) | |
| { | |
| lineEnd = text.size(); | |
| } | |
| std::string line(text.substr(lineStart, lineEnd - lineStart)); | |
| TextToken token { 0, line, false, false }; | |
| ShapedToken shaped = shapeToken(font, token); | |
| float originX = marginX; | |
| float baselineY = marginY + font.ascentPx + lineAdvance * static_cast<float>(lineIndex); | |
| for (const auto &glyph : shaped.glyphs) | |
| { | |
| auto found = frameGlyphs.find(glyph.glyphIndex); | |
| if (found == frameGlyphs.end()) | |
| { | |
| const GlyphPreparation &prep = ensureGlyphPrep(glyph.glyphIndex); | |
| found = frameGlyphs.emplace(glyph.glyphIndex, | |
| appendFrameGlyphData(prep, | |
| frame.curves, | |
| frame.hHeaders, | |
| frame.hIndices, | |
| frame.vHeaders, | |
| frame.vIndices)).first; | |
| } | |
| const LiveFrameGlyphInfo &info = found->second; | |
| const GlyphPreparation &prep = *info.prep; | |
| if (prep.curves.empty()) | |
| { | |
| continue; | |
| } | |
| float glyphOriginX = originX + glyph.offsetX; | |
| float glyphOriginY = baselineY - glyph.offsetY; | |
| appendGlyphPolygonVertices(prep, | |
| info, | |
| jacobian, | |
| [&](const PolygonVertex &vertex) { | |
| return simd_make_float2(glyphOriginX + vertex.position[0], | |
| glyphOriginY - vertex.position[1]); | |
| }, | |
| [&](const PolygonVertex &vertex) { | |
| return simd_make_float2(vertex.normal[0], -vertex.normal[1]); | |
| }, | |
| frame.glyphVertices); | |
| } | |
| if (lineEnd == text.size()) | |
| { | |
| break; | |
| } | |
| lineStart = lineEnd + 1; | |
| ++lineIndex; | |
| } | |
| return frame; | |
| } | |
| private: | |
| const GlyphPreparation &ensureGlyphPrep(uint32_t glyphIndex) const | |
| { | |
| auto found = glyphPrepCache_.find(glyphIndex); | |
| if (found == glyphPrepCache_.end()) | |
| { | |
| found = glyphPrepCache_.emplace(glyphIndex, prepareGlyph(fonts_.front().face, glyphIndex, sizePixels_)).first; | |
| } | |
| return found->second; | |
| } | |
| mutable std::map<uint32_t, GlyphPreparation> glyphPrepCache_; | |
| FT_Library ftLibrary_ = nullptr; | |
| std::vector<FontResource> fonts_; | |
| uint32_t sizePixels_ = 0; | |
| }; | |
| static std::string formatHudText(const HudMetrics &metrics) | |
| { | |
| std::ostringstream out; | |
| out << std::fixed << std::setprecision(1); | |
| out << "FPS " << metrics.fps << '\n'; | |
| out << "CPU Frame " << std::setprecision(2) << metrics.cpuFrameMs << " ms\n"; | |
| out << "GPU Frame " << std::setprecision(2) << metrics.gpuFrameMs << " ms\n"; | |
| out << "Scene Shape " << std::setprecision(2) << metrics.sceneShapeMs << " ms\n"; | |
| out << "Scene Prep " << std::setprecision(2) << metrics.scenePrepMs << " ms\n"; | |
| out << "Glyphs " << metrics.glyphCount << " / " << metrics.uniqueGlyphCount; | |
| return out.str(); | |
| } | |
| class SceneRendererCore | |
| { | |
| public: | |
| SceneRendererCore(id<MTLDevice> device, | |
| LiveParagraphRenderer &liveRenderer, | |
| MTLPixelFormat colorFormat, | |
| MTLPixelFormat depthFormat) | |
| : device_(device) | |
| { | |
| queue_ = [device_ newCommandQueue]; | |
| NSError *error = nil; | |
| NSString *shaderSource = [NSString stringWithUTF8String:kShaderSource]; | |
| id<MTLLibrary> library = [device_ newLibraryWithSource:shaderSource options:nil error:&error]; | |
| if (library == nil) | |
| { | |
| throw std::runtime_error([[error localizedDescription] UTF8String]); | |
| } | |
| id<MTLFunction> pageVertexFunction = [library newFunctionWithName:@"page_vertex"]; | |
| id<MTLFunction> pageBackgroundFunction = [library newFunctionWithName:@"page_background_fragment"]; | |
| id<MTLFunction> glyphVertexFunction = [library newFunctionWithName:@"slug_live_vertex"]; | |
| id<MTLFunction> glyphFragmentFunction = [library newFunctionWithName:@"slug_live_fragment"]; | |
| MTLVertexDescriptor *pageVertexDescriptor = [[MTLVertexDescriptor alloc] init]; | |
| pageVertexDescriptor.attributes[0].format = MTLVertexFormatFloat3; | |
| pageVertexDescriptor.attributes[0].offset = offsetof(PageVertex, position); | |
| pageVertexDescriptor.attributes[0].bufferIndex = 0; | |
| pageVertexDescriptor.attributes[1].format = MTLVertexFormatFloat2; | |
| pageVertexDescriptor.attributes[1].offset = offsetof(PageVertex, texCoord); | |
| pageVertexDescriptor.attributes[1].bufferIndex = 0; | |
| pageVertexDescriptor.layouts[0].stride = sizeof(PageVertex); | |
| pageVertexDescriptor.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex; | |
| MTLRenderPipelineDescriptor *backgroundDescriptor = [[MTLRenderPipelineDescriptor alloc] init]; | |
| backgroundDescriptor.vertexFunction = pageVertexFunction; | |
| backgroundDescriptor.fragmentFunction = pageBackgroundFunction; | |
| backgroundDescriptor.vertexDescriptor = pageVertexDescriptor; | |
| backgroundDescriptor.colorAttachments[0].pixelFormat = colorFormat; | |
| backgroundDescriptor.depthAttachmentPixelFormat = depthFormat; | |
| backgroundPipeline_ = [device_ newRenderPipelineStateWithDescriptor:backgroundDescriptor error:&error]; | |
| if (backgroundPipeline_ == nil) | |
| { | |
| throw std::runtime_error([[error localizedDescription] UTF8String]); | |
| } | |
| MTLVertexDescriptor *glyphVertexDescriptor = [[MTLVertexDescriptor alloc] init]; | |
| glyphVertexDescriptor.attributes[0].format = MTLVertexFormatFloat4; | |
| glyphVertexDescriptor.attributes[0].offset = offsetof(LiveGlyphVertex, posNorm); | |
| glyphVertexDescriptor.attributes[0].bufferIndex = 0; | |
| glyphVertexDescriptor.attributes[1].format = MTLVertexFormatFloat2; | |
| glyphVertexDescriptor.attributes[1].offset = offsetof(LiveGlyphVertex, sampleCoord); | |
| glyphVertexDescriptor.attributes[1].bufferIndex = 0; | |
| glyphVertexDescriptor.attributes[2].format = MTLVertexFormatFloat4; | |
| glyphVertexDescriptor.attributes[2].offset = offsetof(LiveGlyphVertex, jacobian); | |
| glyphVertexDescriptor.attributes[2].bufferIndex = 0; | |
| glyphVertexDescriptor.attributes[3].format = MTLVertexFormatFloat4; | |
| glyphVertexDescriptor.attributes[3].offset = offsetof(LiveGlyphVertex, banding); | |
| glyphVertexDescriptor.attributes[3].bufferIndex = 0; | |
| glyphVertexDescriptor.attributes[4].format = MTLVertexFormatUInt4; | |
| glyphVertexDescriptor.attributes[4].offset = offsetof(LiveGlyphVertex, glyph); | |
| glyphVertexDescriptor.attributes[4].bufferIndex = 0; | |
| glyphVertexDescriptor.layouts[0].stride = sizeof(LiveGlyphVertex); | |
| glyphVertexDescriptor.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex; | |
| MTLRenderPipelineDescriptor *glyphDescriptor = [[MTLRenderPipelineDescriptor alloc] init]; | |
| glyphDescriptor.vertexFunction = glyphVertexFunction; | |
| glyphDescriptor.fragmentFunction = glyphFragmentFunction; | |
| glyphDescriptor.vertexDescriptor = glyphVertexDescriptor; | |
| glyphDescriptor.colorAttachments[0].pixelFormat = colorFormat; | |
| glyphDescriptor.depthAttachmentPixelFormat = depthFormat; | |
| glyphDescriptor.colorAttachments[0].blendingEnabled = YES; | |
| glyphDescriptor.colorAttachments[0].rgbBlendOperation = MTLBlendOperationAdd; | |
| glyphDescriptor.colorAttachments[0].alphaBlendOperation = MTLBlendOperationAdd; | |
| glyphDescriptor.colorAttachments[0].sourceRGBBlendFactor = MTLBlendFactorSourceAlpha; | |
| glyphDescriptor.colorAttachments[0].sourceAlphaBlendFactor = MTLBlendFactorSourceAlpha; | |
| glyphDescriptor.colorAttachments[0].destinationRGBBlendFactor = MTLBlendFactorOneMinusSourceAlpha; | |
| glyphDescriptor.colorAttachments[0].destinationAlphaBlendFactor = MTLBlendFactorOneMinusSourceAlpha; | |
| glyphPipeline_ = [device_ newRenderPipelineStateWithDescriptor:glyphDescriptor error:&error]; | |
| if (glyphPipeline_ == nil) | |
| { | |
| throw std::runtime_error([[error localizedDescription] UTF8String]); | |
| } | |
| MTLDepthStencilDescriptor *depthDescriptor = [[MTLDepthStencilDescriptor alloc] init]; | |
| depthDescriptor.depthCompareFunction = MTLCompareFunctionLessEqual; | |
| depthDescriptor.depthWriteEnabled = YES; | |
| depthState_ = [device_ newDepthStencilStateWithDescriptor:depthDescriptor]; | |
| MTLDepthStencilDescriptor *hudDepthDescriptor = [[MTLDepthStencilDescriptor alloc] init]; | |
| hudDepthDescriptor.depthCompareFunction = MTLCompareFunctionAlways; | |
| hudDepthDescriptor.depthWriteEnabled = NO; | |
| hudDepthState_ = [device_ newDepthStencilStateWithDescriptor:hudDepthDescriptor]; | |
| LiveFrame frame = liveRenderer.buildFrame(); | |
| sceneShapeMs_ = frame.shapeMs; | |
| scenePrepMs_ = frame.prepMs; | |
| sceneGlyphCount_ = frame.glyphCount; | |
| sceneUniqueGlyphCount_ = frame.uniqueGlyphCount; | |
| pageBuffer_ = createPrivateBuffer(device_, queue_, frame.pageVertices); | |
| glyphBuffer_ = createPrivateBuffer(device_, queue_, frame.glyphVertices); | |
| curves_ = createPrivateBuffer(device_, queue_, frame.curves); | |
| hHeaders_ = createPrivateBuffer(device_, queue_, frame.hHeaders); | |
| hIndices_ = createPrivateBuffer(device_, queue_, frame.hIndices); | |
| vHeaders_ = createPrivateBuffer(device_, queue_, frame.vHeaders); | |
| vIndices_ = createPrivateBuffer(device_, queue_, frame.vIndices); | |
| pageVertexCount_ = static_cast<NSUInteger>(frame.pageVertices.size()); | |
| glyphVertexCount_ = static_cast<NSUInteger>(frame.glyphVertices.size()); | |
| } | |
| id<MTLCommandQueue> commandQueue() const | |
| { | |
| return queue_; | |
| } | |
| double sceneShapeMs() const | |
| { | |
| return sceneShapeMs_; | |
| } | |
| double scenePrepMs() const | |
| { | |
| return scenePrepMs_; | |
| } | |
| size_t sceneGlyphCount() const | |
| { | |
| return sceneGlyphCount_; | |
| } | |
| size_t sceneUniqueGlyphCount() const | |
| { | |
| return sceneUniqueGlyphCount_; | |
| } | |
| SceneUniforms sceneUniforms(CGSize size, const CameraState &camera) const | |
| { | |
| SceneUniforms uniforms {}; | |
| float aspect = std::max(0.1f, static_cast<float>(size.width / std::max(size.height, 1.0))); | |
| simd_float3 eye = { | |
| camera.distance * std::cos(camera.pitch) * std::sin(camera.yaw), | |
| camera.distance * std::sin(camera.pitch), | |
| camera.distance * std::cos(camera.pitch) * std::cos(camera.yaw) | |
| }; | |
| simd_float4x4 projection = MatrixPerspective(0.78f, aspect, 0.1f, 50.0f); | |
| simd_float4x4 view = MatrixLookAt(eye, simd_make_float3(0.0f, 0.0f, 0.0f), simd_make_float3(0.0f, 1.0f, 0.0f)); | |
| simd_float4x4 mvp = simd_mul(projection, view); | |
| uniforms.row0 = { mvp.columns[0].x, mvp.columns[1].x, mvp.columns[2].x, mvp.columns[3].x }; | |
| uniforms.row1 = { mvp.columns[0].y, mvp.columns[1].y, mvp.columns[2].y, mvp.columns[3].y }; | |
| uniforms.row2 = { mvp.columns[0].z, mvp.columns[1].z, mvp.columns[2].z, mvp.columns[3].z }; | |
| uniforms.row3 = { mvp.columns[0].w, mvp.columns[1].w, mvp.columns[2].w, mvp.columns[3].w }; | |
| uniforms.viewport = { static_cast<float>(size.width), static_cast<float>(size.height) }; | |
| uniforms.inkColor = simd_make_float4(0.98f, 0.93f, 0.84f, 1.0f); | |
| uniforms.paperColor = simd_make_float4(0.09f, 0.10f, 0.11f, 1.0f); | |
| return uniforms; | |
| } | |
| SceneUniforms hudUniforms(CGSize size) const | |
| { | |
| SceneUniforms uniforms {}; | |
| float width = std::max(1.0f, static_cast<float>(size.width)); | |
| float height = std::max(1.0f, static_cast<float>(size.height)); | |
| uniforms.row0 = { 2.0f / width, 0.0f, 0.0f, -1.0f }; | |
| uniforms.row1 = { 0.0f, -2.0f / height, 0.0f, 1.0f }; | |
| uniforms.row2 = { 0.0f, 0.0f, 1.0f, 0.0f }; | |
| uniforms.row3 = { 0.0f, 0.0f, 0.0f, 1.0f }; | |
| uniforms.viewport = { width, height }; | |
| uniforms.inkColor = simd_make_float4(0.96f, 0.97f, 0.99f, 0.96f); | |
| uniforms.paperColor = simd_make_float4(0.0f, 0.0f, 0.0f, 0.0f); | |
| return uniforms; | |
| } | |
| void encodeFrame(id<MTLRenderCommandEncoder> encoder, | |
| CGSize size, | |
| const CameraState &camera, | |
| HudTextRenderer *hudRenderer, | |
| const HudMetrics *hudMetrics) | |
| { | |
| SceneUniforms uniforms = sceneUniforms(size, camera); | |
| [encoder setCullMode:MTLCullModeNone]; | |
| [encoder setDepthStencilState:depthState_]; | |
| [encoder setRenderPipelineState:backgroundPipeline_]; | |
| [encoder setVertexBuffer:pageBuffer_ offset:0 atIndex:0]; | |
| [encoder setVertexBytes:&uniforms length:sizeof(uniforms) atIndex:1]; | |
| [encoder setFragmentBytes:&uniforms length:sizeof(uniforms) atIndex:1]; | |
| [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:pageVertexCount_]; | |
| if (glyphVertexCount_ == 0) | |
| { | |
| return; | |
| } | |
| [encoder setRenderPipelineState:glyphPipeline_]; | |
| [encoder setVertexBuffer:glyphBuffer_ offset:0 atIndex:0]; | |
| [encoder setVertexBytes:&uniforms length:sizeof(uniforms) atIndex:1]; | |
| [encoder setFragmentBytes:&uniforms length:sizeof(uniforms) atIndex:1]; | |
| [encoder setFragmentBuffer:curves_ offset:0 atIndex:2]; | |
| [encoder setFragmentBuffer:hHeaders_ offset:0 atIndex:3]; | |
| [encoder setFragmentBuffer:hIndices_ offset:0 atIndex:4]; | |
| [encoder setFragmentBuffer:vHeaders_ offset:0 atIndex:5]; | |
| [encoder setFragmentBuffer:vIndices_ offset:0 atIndex:6]; | |
| [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:glyphVertexCount_]; | |
| if (hudRenderer != nullptr && hudMetrics != nullptr) | |
| { | |
| HudFrame hudFrame = hudRenderer->buildFrame(formatHudText(*hudMetrics)); | |
| id<MTLBuffer> hudGlyphBuffer = createSharedBuffer(device_, hudFrame.glyphVertices); | |
| id<MTLBuffer> hudCurves = createSharedBuffer(device_, hudFrame.curves); | |
| id<MTLBuffer> hudHHeaders = createSharedBuffer(device_, hudFrame.hHeaders); | |
| id<MTLBuffer> hudHIndices = createSharedBuffer(device_, hudFrame.hIndices); | |
| id<MTLBuffer> hudVHeaders = createSharedBuffer(device_, hudFrame.vHeaders); | |
| id<MTLBuffer> hudVIndices = createSharedBuffer(device_, hudFrame.vIndices); | |
| SceneUniforms hudValues = hudUniforms(size); | |
| [encoder setDepthStencilState:hudDepthState_]; | |
| [encoder setRenderPipelineState:glyphPipeline_]; | |
| [encoder setVertexBuffer:hudGlyphBuffer offset:0 atIndex:0]; | |
| [encoder setVertexBytes:&hudValues length:sizeof(hudValues) atIndex:1]; | |
| [encoder setFragmentBytes:&hudValues length:sizeof(hudValues) atIndex:1]; | |
| [encoder setFragmentBuffer:hudCurves offset:0 atIndex:2]; | |
| [encoder setFragmentBuffer:hudHHeaders offset:0 atIndex:3]; | |
| [encoder setFragmentBuffer:hudHIndices offset:0 atIndex:4]; | |
| [encoder setFragmentBuffer:hudVHeaders offset:0 atIndex:5]; | |
| [encoder setFragmentBuffer:hudVIndices offset:0 atIndex:6]; | |
| [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:(NSUInteger)hudFrame.glyphVertices.size()]; | |
| } | |
| } | |
| private: | |
| id<MTLDevice> device_ = nil; | |
| id<MTLCommandQueue> queue_ = nil; | |
| id<MTLRenderPipelineState> backgroundPipeline_ = nil; | |
| id<MTLRenderPipelineState> glyphPipeline_ = nil; | |
| id<MTLDepthStencilState> depthState_ = nil; | |
| id<MTLDepthStencilState> hudDepthState_ = nil; | |
| id<MTLBuffer> pageBuffer_ = nil; | |
| id<MTLBuffer> glyphBuffer_ = nil; | |
| id<MTLBuffer> curves_ = nil; | |
| id<MTLBuffer> hHeaders_ = nil; | |
| id<MTLBuffer> hIndices_ = nil; | |
| id<MTLBuffer> vHeaders_ = nil; | |
| id<MTLBuffer> vIndices_ = nil; | |
| NSUInteger pageVertexCount_ = 0; | |
| NSUInteger glyphVertexCount_ = 0; | |
| double sceneShapeMs_ = 0.0; | |
| double scenePrepMs_ = 0.0; | |
| size_t sceneGlyphCount_ = 0; | |
| size_t sceneUniqueGlyphCount_ = 0; | |
| }; | |
| } // namespace slug | |
| @interface ParagraphSceneRenderer : NSObject <MTKViewDelegate> | |
| - (instancetype)initWithView:(MTKView *)view runtime:(slug::AppRuntime *)runtime; | |
| - (void)rotateByDeltaX:(float)deltaX deltaY:(float)deltaY; | |
| - (void)zoomBy:(float)delta; | |
| - (void)resetCamera; | |
| @end | |
| @implementation ParagraphSceneRenderer | |
| { | |
| __weak MTKView *_view; | |
| slug::AppRuntime *_runtime; | |
| std::unique_ptr<slug::SceneRendererCore> _core; | |
| slug::CameraState _camera; | |
| CFTimeInterval _lastFrameTime; | |
| double _smoothedFps; | |
| slug::HudMetrics _lastMetrics; | |
| } | |
| - (instancetype)initWithView:(MTKView *)view runtime:(slug::AppRuntime *)runtime | |
| { | |
| self = [super init]; | |
| if (!self) | |
| { | |
| return nil; | |
| } | |
| _view = view; | |
| _runtime = runtime; | |
| _camera = slug::CameraState {}; | |
| _core = std::make_unique<slug::SceneRendererCore>(view.device, | |
| *runtime->liveRenderer, | |
| view.colorPixelFormat, | |
| view.depthStencilPixelFormat); | |
| return self; | |
| } | |
| - (void)resetCamera | |
| { | |
| _camera = slug::CameraState {}; | |
| } | |
| - (void)rotateByDeltaX:(float)deltaX deltaY:(float)deltaY | |
| { | |
| _camera.yaw += deltaX * 0.01f; | |
| _camera.pitch += deltaY * 0.01f; | |
| _camera.pitch = std::clamp(_camera.pitch, -1.2f, 1.2f); | |
| } | |
| - (void)zoomBy:(float)delta | |
| { | |
| float factor = std::exp(delta * 0.01f); | |
| _camera.distance = std::clamp(_camera.distance * factor, 1.2f, 10.0f); | |
| } | |
| - (void)mtkView:(MTKView *)view drawableSizeWillChange:(CGSize)size | |
| { | |
| (void)view; | |
| (void)size; | |
| } | |
| - (void)drawInMTKView:(MTKView *)view | |
| { | |
| if (view.currentDrawable == nil || view.currentRenderPassDescriptor == nil) | |
| { | |
| return; | |
| } | |
| CFTimeInterval frameStart = CACurrentMediaTime(); | |
| if (_lastFrameTime > 0.0) | |
| { | |
| double deltaSeconds = std::max(frameStart - _lastFrameTime, 1.0 / 10000.0); | |
| double instantFps = 1.0 / deltaSeconds; | |
| _smoothedFps = (_smoothedFps <= 0.0) ? instantFps : (_smoothedFps * 0.9 + instantFps * 0.1); | |
| } | |
| _lastFrameTime = frameStart; | |
| slug::HudMetrics displayMetrics = _lastMetrics; | |
| displayMetrics.fps = _smoothedFps; | |
| displayMetrics.sceneShapeMs = _core->sceneShapeMs(); | |
| displayMetrics.scenePrepMs = _core->scenePrepMs(); | |
| displayMetrics.glyphCount = _core->sceneGlyphCount(); | |
| displayMetrics.uniqueGlyphCount = _core->sceneUniqueGlyphCount(); | |
| id<MTLCommandBuffer> commandBuffer = [_core->commandQueue() commandBuffer]; | |
| id<MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor:view.currentRenderPassDescriptor]; | |
| _core->encodeFrame(encoder, view.drawableSize, _camera, _runtime->hudRenderer.get(), &displayMetrics); | |
| [encoder endEncoding]; | |
| CFTimeInterval frameEnd = CACurrentMediaTime(); | |
| _lastMetrics = displayMetrics; | |
| _lastMetrics.cpuFrameMs = (frameEnd - frameStart) * 1000.0; | |
| __weak ParagraphSceneRenderer *weakSelf = self; | |
| [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> completedBuffer) { | |
| double gpuMs = 0.0; | |
| if (completedBuffer.GPUStartTime > 0.0 && | |
| completedBuffer.GPUEndTime > completedBuffer.GPUStartTime) | |
| { | |
| gpuMs = (completedBuffer.GPUEndTime - completedBuffer.GPUStartTime) * 1000.0; | |
| } | |
| dispatch_async(dispatch_get_main_queue(), ^{ | |
| ParagraphSceneRenderer *strongSelf = weakSelf; | |
| if (strongSelf != nil) | |
| { | |
| strongSelf->_lastMetrics.gpuFrameMs = gpuMs; | |
| } | |
| }); | |
| }]; | |
| [commandBuffer presentDrawable:view.currentDrawable]; | |
| [commandBuffer commit]; | |
| } | |
| @end | |
| @interface ParagraphView : MTKView | |
| @property(nonatomic, strong) ParagraphSceneRenderer *sceneRenderer; | |
| @end | |
| @implementation ParagraphView | |
| { | |
| NSPoint _lastPoint; | |
| } | |
| - (BOOL)acceptsFirstResponder | |
| { | |
| return YES; | |
| } | |
| - (void)mouseDown:(NSEvent *)event | |
| { | |
| _lastPoint = [self convertPoint:event.locationInWindow fromView:nil]; | |
| } | |
| - (void)mouseDragged:(NSEvent *)event | |
| { | |
| NSPoint point = [self convertPoint:event.locationInWindow fromView:nil]; | |
| CGFloat deltaX = point.x - _lastPoint.x; | |
| CGFloat deltaY = point.y - _lastPoint.y; | |
| [self.sceneRenderer rotateByDeltaX:(float)deltaX deltaY:(float)deltaY]; | |
| _lastPoint = point; | |
| } | |
| - (void)scrollWheel:(NSEvent *)event | |
| { | |
| [self.sceneRenderer zoomBy:(float)(-event.scrollingDeltaY)]; | |
| } | |
| - (void)magnifyWithEvent:(NSEvent *)event | |
| { | |
| [self.sceneRenderer zoomBy:(float)(-event.magnification * 120.0)]; | |
| } | |
| - (void)keyDown:(NSEvent *)event | |
| { | |
| NSString *characters = event.charactersIgnoringModifiers.lowercaseString; | |
| if ([characters isEqualToString:@"r"]) | |
| { | |
| [self.sceneRenderer resetCamera]; | |
| } | |
| else | |
| { | |
| [super keyDown:event]; | |
| } | |
| } | |
| @end | |
| static void CreateMenuBar(void) | |
| { | |
| NSMenu *menuBar = [[NSMenu alloc] init]; | |
| NSMenuItem *appMenuItem = [[NSMenuItem alloc] init]; | |
| [menuBar addItem:appMenuItem]; | |
| [NSApp setMainMenu:menuBar]; | |
| NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"Slug"]; | |
| NSString *quitTitle = [@"Quit " stringByAppendingString:NSProcessInfo.processInfo.processName]; | |
| NSMenuItem *quitItem = [[NSMenuItem alloc] initWithTitle:quitTitle | |
| action:@selector(terminate:) | |
| keyEquivalent:@"q"]; | |
| [appMenu addItem:quitItem]; | |
| [appMenuItem setSubmenu:appMenu]; | |
| } | |
| @interface AppDelegate : NSObject <NSApplicationDelegate, NSWindowDelegate> | |
| @property(nonatomic) slug::AppRuntime *runtime; | |
| @property(nonatomic, strong) NSWindow *window; | |
| @property(nonatomic, strong) ParagraphView *view; | |
| @property(nonatomic, strong) ParagraphSceneRenderer *renderer; | |
| @end | |
| @implementation AppDelegate | |
| - (void)applicationDidFinishLaunching:(NSNotification *)notification | |
| { | |
| (void)notification; | |
| CreateMenuBar(); | |
| NSRect frame = NSMakeRect(0.0, 0.0, 1280.0, 860.0); | |
| self.window = [[NSWindow alloc] initWithContentRect:frame | |
| styleMask:(NSWindowStyleMaskTitled | | |
| NSWindowStyleMaskClosable | | |
| NSWindowStyleMaskMiniaturizable | | |
| NSWindowStyleMaskResizable) | |
| backing:NSBackingStoreBuffered | |
| defer:NO]; | |
| self.window.delegate = self; | |
| self.window.title = @"Slug Text Renderer drag to orbit scroll to zoom R reset"; | |
| self.view = [[ParagraphView alloc] initWithFrame:self.window.contentView.bounds device:self.runtime->device]; | |
| self.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; | |
| self.view.colorPixelFormat = MTLPixelFormatBGRA8Unorm_sRGB; | |
| self.view.depthStencilPixelFormat = MTLPixelFormatDepth32Float; | |
| self.view.framebufferOnly = YES; | |
| self.view.clearColor = MTLClearColorMake(0.035, 0.040, 0.050, 1.0); | |
| self.view.preferredFramesPerSecond = 60; | |
| self.view.paused = NO; | |
| self.view.enableSetNeedsDisplay = NO; | |
| self.renderer = [[ParagraphSceneRenderer alloc] initWithView:self.view runtime:self.runtime]; | |
| self.view.delegate = self.renderer; | |
| self.view.sceneRenderer = self.renderer; | |
| [self.window.contentView addSubview:self.view]; | |
| [self.window center]; | |
| [self.window makeKeyAndOrderFront:nil]; | |
| [self.window makeFirstResponder:self.view]; | |
| [NSApp activateIgnoringOtherApps:YES]; | |
| } | |
| - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender | |
| { | |
| (void)sender; | |
| return YES; | |
| } | |
| - (void)windowWillClose:(NSNotification *)notification | |
| { | |
| (void)notification; | |
| [NSApp terminate:nil]; | |
| } | |
| @end | |
| namespace slug | |
| { | |
| static id gAppDelegate = nil; | |
| static int runApp(AppRuntime &runtime) | |
| { | |
| NSApplication *application = [NSApplication sharedApplication]; | |
| [application setActivationPolicy:NSApplicationActivationPolicyRegular]; | |
| AppDelegate *delegate = [[AppDelegate alloc] init]; | |
| delegate.runtime = &runtime; | |
| gAppDelegate = delegate; | |
| application.delegate = delegate; | |
| [application run]; | |
| gAppDelegate = nil; | |
| return 0; | |
| } | |
| } // namespace slug | |
| int main() | |
| { | |
| @autoreleasepool | |
| { | |
| try | |
| { | |
| slug::fs::path root = slug::locateProjectRoot(); | |
| std::vector<slug::fs::path> fontPaths = slug::discoverFonts(root); | |
| if (fontPaths.empty()) | |
| { | |
| throw std::runtime_error("No .ttf fonts found in project root"); | |
| } | |
| slug::AppRuntime runtime; | |
| runtime.device = MTLCreateSystemDefaultDevice(); | |
| if (runtime.device == nil) | |
| { | |
| throw std::runtime_error("Metal device unavailable"); | |
| } | |
| runtime.liveRenderer = std::make_unique<slug::LiveParagraphRenderer>(fontPaths, | |
| slug::kFontSizePixels, | |
| slug::kParagraphWidth); | |
| slug::fs::path hudFontPath = root / "Mohave-Regular.ttf"; | |
| if (!slug::fs::exists(hudFontPath)) | |
| { | |
| throw std::runtime_error("Mohave-Regular.ttf is required for the HUD overlay"); | |
| } | |
| runtime.hudRenderer = std::make_unique<slug::HudTextRenderer>(hudFontPath, 20); | |
| return slug::runApp(runtime); | |
| } | |
| catch (const std::exception &exception) | |
| { | |
| std::cerr << exception.what() << '\n'; | |
| return 1; | |
| } | |
| catch (NSException *exception) | |
| { | |
| std::cerr << exception.reason.UTF8String << '\n'; | |
| return 1; | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment