mirror of
https://github.com/encounter/aurora.git
synced 2026-07-09 18:19:33 -07:00
Public aurora::gfx API (custom draws & compute)
This commit is contained in:
@@ -101,8 +101,11 @@ void aurora_dvd_overlay_callbacks(const AuroraOverlayCallbacks* callbacks);
|
||||
/**
|
||||
* \brief Specify a set of overlay files to be used by the DVD layer.
|
||||
*
|
||||
* Calling this function immediately applies the new files and rebuilds the FST. This is not thread safe.
|
||||
* It is best you only call this once on startup, before the game's code has started.
|
||||
* Calling this function immediately applies the new files and rebuilds the FST, replacing any
|
||||
* previously specified set. It may be called again at runtime; FST reads and overlay opens are
|
||||
* serialized against the rebuild, and previously assigned EntryNums are stable per path. Files
|
||||
* removed by a re-registration fail to open from then on (or revert to the underlying DVD file);
|
||||
* already-open handles are unaffected. Only call from one thread at a time.
|
||||
*
|
||||
* This function must be called *after* aurora_dvd_overlay_callbacks.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
|
||||
namespace aurora::gfx {
|
||||
|
||||
inline constexpr size_t InlineDrawPayloadSize = 128;
|
||||
|
||||
/// Generational handle: 0 is never valid, and IDs are not reused after unregister_draw_type.
|
||||
using DrawTypeId = uint64_t;
|
||||
inline constexpr DrawTypeId InvalidDrawType = 0;
|
||||
|
||||
struct Range {
|
||||
uint32_t offset = 0;
|
||||
uint32_t size = 0;
|
||||
|
||||
bool operator==(const Range& rhs) const { return offset == rhs.offset && size == rhs.size; }
|
||||
bool operator!=(const Range& rhs) const { return !(*this == rhs); }
|
||||
};
|
||||
|
||||
struct DrawContext {
|
||||
wgpu::Device device;
|
||||
wgpu::Queue queue;
|
||||
wgpu::Buffer vertexBuffer;
|
||||
wgpu::Buffer indexBuffer;
|
||||
wgpu::Buffer uniformBuffer;
|
||||
wgpu::Buffer storageBuffer;
|
||||
wgpu::TextureFormat colorFormat;
|
||||
wgpu::TextureFormat depthFormat;
|
||||
uint32_t sampleCount = 1;
|
||||
uint32_t targetWidth = 0;
|
||||
uint32_t targetHeight = 0;
|
||||
};
|
||||
|
||||
/// Invoked on the render worker thread while replaying the pass the draw was
|
||||
/// recorded into. The encoder's pipeline/bind-group/viewport/scissor state is
|
||||
/// restored after the callback returns. Handles in the context are borrowed and
|
||||
/// valid only for the duration of the call. sampleCount/target dimensions are
|
||||
/// those of the containing pass (offscreen passes are always single-sample).
|
||||
using DrawCallback = void (*)(const DrawContext& ctx, const wgpu::RenderPassEncoder& pass,
|
||||
const void* payload, size_t payloadSize, void* userdata);
|
||||
|
||||
struct DrawTypeDescriptor {
|
||||
const char* label = nullptr;
|
||||
DrawCallback draw = nullptr;
|
||||
void* userdata = nullptr;
|
||||
};
|
||||
|
||||
wgpu::Device device() noexcept;
|
||||
wgpu::Queue queue() noexcept;
|
||||
wgpu::TextureFormat color_format() noexcept;
|
||||
wgpu::TextureFormat depth_format() noexcept;
|
||||
uint32_t sample_count() noexcept;
|
||||
bool uses_reversed_z() noexcept;
|
||||
|
||||
DrawTypeId register_draw_type(const DrawTypeDescriptor& desc);
|
||||
void unregister_draw_type(DrawTypeId type) noexcept;
|
||||
/// Records an inline custom draw into the currently open render pass at the
|
||||
/// current position in the command stream. Payload (<= InlineDrawPayloadSize)
|
||||
/// is copied. Returns false (with a warning) outside an active render pass.
|
||||
bool push_custom_draw(DrawTypeId type, const void* payload, size_t payloadSize);
|
||||
|
||||
/// Generational handle: 0 is never valid, and IDs are not reused after unregister_encoder_task_type.
|
||||
using EncoderTaskId = uint64_t;
|
||||
inline constexpr EncoderTaskId InvalidEncoderTask = 0;
|
||||
|
||||
struct EncoderTaskContext {
|
||||
wgpu::Device device;
|
||||
wgpu::Queue queue;
|
||||
wgpu::Buffer vertexBuffer;
|
||||
wgpu::Buffer indexBuffer;
|
||||
wgpu::Buffer uniformBuffer;
|
||||
wgpu::Buffer storageBuffer;
|
||||
};
|
||||
|
||||
/// Invoked on the render worker thread with the frame's command encoder,
|
||||
/// positioned between two render passes. The callback may begin/end compute
|
||||
/// passes and record copies on the encoder; it must leave no pass open when it
|
||||
/// returns and must not Finish the encoder. Handles in the context are borrowed
|
||||
/// and valid only for the duration of the call. Data appended to the streaming
|
||||
/// buffers before the task was pushed is GPU-visible inside it.
|
||||
using EncoderTaskCallback = void (*)(const EncoderTaskContext& ctx, const wgpu::CommandEncoder& cmd,
|
||||
const void* payload, size_t payloadSize, void* userdata);
|
||||
|
||||
struct EncoderTaskDescriptor {
|
||||
const char* label = nullptr;
|
||||
EncoderTaskCallback callback = nullptr;
|
||||
void* userdata = nullptr;
|
||||
};
|
||||
|
||||
EncoderTaskId register_encoder_task_type(const EncoderTaskDescriptor& desc);
|
||||
void unregister_encoder_task_type(EncoderTaskId type) noexcept;
|
||||
/// Seals the current EFB pass, records an encoder task to execute on the frame
|
||||
/// encoder at this point, and resumes rendering on a pass that loads the
|
||||
/// existing contents. Payload semantics match push_custom_draw. Returns false
|
||||
/// (with a warning) outside an active render pass or while an offscreen pass
|
||||
/// is open.
|
||||
bool push_encoder_task(EncoderTaskId type, const void* payload, size_t payloadSize);
|
||||
|
||||
/// Append transient data to the shared per-frame streaming buffers. Returned
|
||||
/// ranges are valid for the current frame only. Returns an empty Range (with a
|
||||
/// warning) outside an active recording frame.
|
||||
Range push_verts(const uint8_t* data, size_t length, size_t alignment);
|
||||
Range push_indices(const uint8_t* data, size_t length, size_t alignment);
|
||||
Range push_uniform(const uint8_t* data, size_t length);
|
||||
Range push_storage(const uint8_t* data, size_t length);
|
||||
|
||||
struct ResolveDesc {
|
||||
bool color = true;
|
||||
bool depth = false;
|
||||
};
|
||||
|
||||
struct ResolvedTargets {
|
||||
wgpu::TextureView color; // single-sample snapshot; null if not requested
|
||||
wgpu::TextureView depth; // single-sample R32Float depth snapshot; null if not requested
|
||||
wgpu::TextureFormat colorFormat = wgpu::TextureFormat::Undefined;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
};
|
||||
|
||||
/// Snapshots the current pass targets into pooled textures (valid for the
|
||||
/// current frame), then: on the EFB, continues rendering on a fresh EFB pass
|
||||
/// (GXCopyTex semantics); in an offscreen pass created by create_pass, ends it
|
||||
/// and restores the suspended EFB pass (GXRestoreFrameBuffer semantics).
|
||||
/// Requesting neither color nor depth is a plain pass break (or offscreen
|
||||
/// close, discarding its output). Depth is left null when unsupported by the
|
||||
/// device. Returns false (with a warning) outside an active render pass.
|
||||
bool resolve_pass(const ResolveDesc& desc, ResolvedTargets& out);
|
||||
|
||||
/// Opens an offscreen render pass (GXCreateFrameBuffer semantics): cleared
|
||||
/// single-sample color+depth at (width, height) with full-target
|
||||
/// viewport/scissor. Subsequent draws target it until resolve_pass restores the
|
||||
/// EFB. Nesting is unsupported: returns false (with a warning) outside an
|
||||
/// active render pass or while any offscreen pass is already open.
|
||||
bool create_pass(uint32_t width, uint32_t height);
|
||||
|
||||
/// True while an offscreen pass (create_pass or GXCreateFrameBuffer) is open.
|
||||
bool is_offscreen() noexcept;
|
||||
|
||||
/// Blocks until the render worker has drained its queue. After this returns,
|
||||
/// no draw callback is executing or queued to execute; used before unloading
|
||||
/// code that registered draw types. Callable from the game thread only.
|
||||
void synchronize();
|
||||
|
||||
} // namespace aurora::gfx
|
||||
@@ -78,8 +78,8 @@ void copy_tex(const void* dest, GXBool clear) noexcept {
|
||||
const auto clearColor = clear && g_gxState.colorUpdate;
|
||||
const auto clearAlpha = clear && g_gxState.alphaUpdate;
|
||||
const auto clearDepth = clear && g_gxState.depthUpdate;
|
||||
gfx::resolve_pass(handle.handle, rect, clearColor, clearAlpha, clearDepth, g_gxState.clearColor, clear_depth_value(),
|
||||
texCopyFmt);
|
||||
gfx::resolve_pass_into(handle.handle, rect, clearColor, clearAlpha, clearDepth, g_gxState.clearColor,
|
||||
clear_depth_value(), texCopyFmt);
|
||||
++handle.revision;
|
||||
g_gxState.copyTextures[dest] = handle;
|
||||
}
|
||||
|
||||
+587
-25
File diff suppressed because it is too large
Load Diff
+4
-11
@@ -11,6 +11,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include <aurora/gfx.h>
|
||||
#include <aurora/gfx.hpp>
|
||||
#include <aurora/math.hpp>
|
||||
#include <dolphin/gx/GXEnum.h>
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
@@ -173,7 +174,7 @@ private:
|
||||
namespace aurora::gfx {
|
||||
inline constexpr bool UseTextureBuffer = true;
|
||||
inline constexpr uint64_t UniformBufferSize = 25165824; // 24mb
|
||||
inline constexpr uint64_t VertexBufferSize = 3145728; // 3mb
|
||||
inline constexpr uint64_t VertexBufferSize = 5242880; // 5mb
|
||||
inline constexpr uint64_t IndexBufferSize = 1048576; // 1mb
|
||||
inline constexpr uint64_t StorageBufferSize = 8388608; // 8mb
|
||||
inline constexpr uint64_t TextureUploadSize = 25165824; // 24mb
|
||||
@@ -194,13 +195,6 @@ using BindGroupRef = HashType;
|
||||
using PipelineRef = HashType;
|
||||
using SamplerRef = HashType;
|
||||
using ShaderRef = HashType;
|
||||
struct Range {
|
||||
uint32_t offset = 0;
|
||||
uint32_t size = 0;
|
||||
|
||||
bool operator==(const Range& rhs) const { return memcmp(this, &rhs, sizeof(*this)) == 0; }
|
||||
bool operator!=(const Range& rhs) const { return !(*this == rhs); }
|
||||
};
|
||||
|
||||
struct ClipRect {
|
||||
int32_t x;
|
||||
@@ -236,8 +230,8 @@ void after_submit() noexcept;
|
||||
void gpu_synchronize();
|
||||
void after_present() noexcept;
|
||||
float calculate_fps() noexcept;
|
||||
void resolve_pass(TextureHandle texture, ClipRect rect, bool clearColor, bool clearAlpha, bool clearDepth,
|
||||
Vec4<float> clearColorValue, float clearDepthValue, GXTexFmt resolveFormat = GX_TF_RGBA8);
|
||||
void resolve_pass_into(TextureHandle texture, ClipRect rect, bool clearColor, bool clearAlpha, bool clearDepth,
|
||||
Vec4<float> clearColorValue, float clearDepthValue, GXTexFmt resolveFormat = GX_TF_RGBA8);
|
||||
|
||||
struct ColorPassDescriptor {
|
||||
const char* label = nullptr;
|
||||
@@ -257,7 +251,6 @@ struct ColorPassDescriptor {
|
||||
wgpu::LoadOp stencilLoadOp = wgpu::LoadOp::Undefined;
|
||||
wgpu::StoreOp stencilStoreOp = wgpu::StoreOp::Undefined;
|
||||
uint32_t stencilClearValue = 0;
|
||||
bool observable = true;
|
||||
};
|
||||
|
||||
void begin_color_pass(const ColorPassDescriptor& desc);
|
||||
|
||||
@@ -244,6 +244,45 @@ static constexpr std::string_view FragZ16 = R"(
|
||||
}
|
||||
)"sv;
|
||||
|
||||
// Depth -> R32Float (no scaling)
|
||||
static constexpr std::string_view DepthSnapshotShader = R"(
|
||||
@group(0) @binding(0) var src: texture_depth_2d;
|
||||
|
||||
var<private> positions: array<vec2f, 3> = array(
|
||||
vec2f(-1.0, 1.0),
|
||||
vec2f(-1.0, -3.0),
|
||||
vec2f(3.0, 1.0),
|
||||
);
|
||||
|
||||
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||||
return vec4f(positions[vi], 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
||||
let depth = textureLoad(src, vec2i(pos.xy), 0);
|
||||
return vec4f(depth, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)"sv;
|
||||
|
||||
static constexpr std::string_view DepthSnapshotShaderMS = R"(
|
||||
@group(0) @binding(0) var src: texture_depth_multisampled_2d;
|
||||
|
||||
var<private> positions: array<vec2f, 3> = array(
|
||||
vec2f(-1.0, 1.0),
|
||||
vec2f(-1.0, -3.0),
|
||||
vec2f(3.0, 1.0),
|
||||
);
|
||||
|
||||
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
||||
return vec4f(positions[vi], 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
||||
let depth = textureLoad(src, vec2i(pos.xy), 0);
|
||||
return vec4f(depth, 0.0, 0.0, 1.0);
|
||||
}
|
||||
)"sv;
|
||||
|
||||
struct ConvPipeline {
|
||||
GXTexFmt fmt;
|
||||
std::string_view fragShader;
|
||||
@@ -278,6 +317,75 @@ static wgpu::Sampler g_nearestSampler;
|
||||
static wgpu::Sampler g_linearSampler;
|
||||
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
|
||||
static wgpu::RenderPipeline g_blitPipeline;
|
||||
static wgpu::BindGroupLayout g_depthSnapshotBindGroupLayout;
|
||||
static wgpu::BindGroupLayout g_depthSnapshotBindGroupLayoutMS;
|
||||
static wgpu::RenderPipeline g_depthSnapshotPipeline;
|
||||
static wgpu::RenderPipeline g_depthSnapshotPipelineMS;
|
||||
|
||||
static wgpu::BindGroupLayout create_depth_snapshot_layout(bool multisampled, const char* label) {
|
||||
const std::array entries{
|
||||
wgpu::BindGroupLayoutEntry{
|
||||
.binding = 0,
|
||||
.visibility = wgpu::ShaderStage::Fragment,
|
||||
.texture =
|
||||
wgpu::TextureBindingLayout{
|
||||
.sampleType = wgpu::TextureSampleType::Depth,
|
||||
.viewDimension = wgpu::TextureViewDimension::e2D,
|
||||
.multisampled = multisampled,
|
||||
},
|
||||
},
|
||||
};
|
||||
const wgpu::BindGroupLayoutDescriptor descriptor{
|
||||
.label = label,
|
||||
.entryCount = entries.size(),
|
||||
.entries = entries.data(),
|
||||
};
|
||||
return g_device.CreateBindGroupLayout(&descriptor);
|
||||
}
|
||||
|
||||
static wgpu::RenderPipeline create_depth_snapshot_pipeline(const std::string_view shaderSource,
|
||||
const wgpu::BindGroupLayout& bindGroupLayout,
|
||||
const char* label) {
|
||||
const std::string source{shaderSource};
|
||||
const wgpu::ShaderSourceWGSL wgslSource{wgpu::ShaderSourceWGSL::Init{
|
||||
.code = source.c_str(),
|
||||
}};
|
||||
const wgpu::ShaderModuleDescriptor moduleDescriptor{
|
||||
.nextInChain = &wgslSource,
|
||||
.label = label,
|
||||
};
|
||||
const auto module = g_device.CreateShaderModule(&moduleDescriptor);
|
||||
|
||||
const std::array colorTargets{wgpu::ColorTargetState{
|
||||
.format = wgpu::TextureFormat::R32Float,
|
||||
}};
|
||||
const wgpu::FragmentState fragmentState{
|
||||
.module = module,
|
||||
.entryPoint = "fs_main",
|
||||
.targetCount = colorTargets.size(),
|
||||
.targets = colorTargets.data(),
|
||||
};
|
||||
const wgpu::PipelineLayoutDescriptor layoutDescriptor{
|
||||
.bindGroupLayoutCount = 1,
|
||||
.bindGroupLayouts = &bindGroupLayout,
|
||||
};
|
||||
const auto pipelineLayout = g_device.CreatePipelineLayout(&layoutDescriptor);
|
||||
const wgpu::RenderPipelineDescriptor pipelineDescriptor{
|
||||
.label = label,
|
||||
.layout = pipelineLayout,
|
||||
.vertex =
|
||||
wgpu::VertexState{
|
||||
.module = module,
|
||||
.entryPoint = "vs_main",
|
||||
},
|
||||
.primitive =
|
||||
wgpu::PrimitiveState{
|
||||
.topology = wgpu::PrimitiveTopology::TriangleList,
|
||||
},
|
||||
.fragment = &fragmentState,
|
||||
};
|
||||
return g_device.CreateRenderPipeline(&pipelineDescriptor);
|
||||
}
|
||||
|
||||
static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble,
|
||||
const wgpu::BindGroupLayout& bindGroupLayout) {
|
||||
@@ -408,6 +516,12 @@ void initialize() {
|
||||
Log.fatal("Output format mismatch for {}", conv.fmt);
|
||||
}
|
||||
}
|
||||
g_depthSnapshotBindGroupLayout = create_depth_snapshot_layout(false, "Depth Snapshot Bind Group Layout");
|
||||
g_depthSnapshotBindGroupLayoutMS = create_depth_snapshot_layout(true, "Depth Snapshot MS Bind Group Layout");
|
||||
g_depthSnapshotPipeline =
|
||||
create_depth_snapshot_pipeline(DepthSnapshotShader, g_depthSnapshotBindGroupLayout, "Depth Snapshot");
|
||||
g_depthSnapshotPipelineMS =
|
||||
create_depth_snapshot_pipeline(DepthSnapshotShaderMS, g_depthSnapshotBindGroupLayoutMS, "Depth Snapshot MS");
|
||||
}
|
||||
|
||||
static constexpr wgpu::SamplerDescriptor nearestSamplerDescriptor{
|
||||
@@ -432,6 +546,10 @@ void shutdown() {
|
||||
g_depthBindGroupLayout = {};
|
||||
g_nearestSampler = {};
|
||||
g_linearSampler = {};
|
||||
g_depthSnapshotBindGroupLayout = {};
|
||||
g_depthSnapshotBindGroupLayoutMS = {};
|
||||
g_depthSnapshotPipeline = {};
|
||||
g_depthSnapshotPipelineMS = {};
|
||||
}
|
||||
|
||||
static void execute(const wgpu::CommandEncoder& cmd, const ConvRequest& req, const wgpu::RenderPipeline& pipeline) {
|
||||
@@ -519,4 +637,47 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { execute(cmd, req, g_blitPipeline); }
|
||||
|
||||
bool snapshot_depth_supported() noexcept { return static_cast<bool>(g_depthSnapshotPipeline); }
|
||||
|
||||
void snapshot_depth(const wgpu::CommandEncoder& cmd, const wgpu::TextureView& srcDepth, uint32_t msaaSamples,
|
||||
const wgpu::TextureView& dst) {
|
||||
const bool multisampled = msaaSamples > 1;
|
||||
const auto& pipeline = multisampled ? g_depthSnapshotPipelineMS : g_depthSnapshotPipeline;
|
||||
if (!pipeline) {
|
||||
return;
|
||||
}
|
||||
const std::array bindGroupEntries{
|
||||
wgpu::BindGroupEntry{
|
||||
.binding = 0,
|
||||
.textureView = srcDepth,
|
||||
},
|
||||
};
|
||||
const wgpu::BindGroupDescriptor bindGroupDescriptor{
|
||||
.layout = multisampled ? g_depthSnapshotBindGroupLayoutMS : g_depthSnapshotBindGroupLayout,
|
||||
.entryCount = bindGroupEntries.size(),
|
||||
.entries = bindGroupEntries.data(),
|
||||
};
|
||||
const auto bindGroup = g_device.CreateBindGroup(&bindGroupDescriptor);
|
||||
|
||||
const std::array colorAttachments{
|
||||
wgpu::RenderPassColorAttachment{
|
||||
.view = dst,
|
||||
.loadOp = wgpu::LoadOp::Clear,
|
||||
.storeOp = wgpu::StoreOp::Store,
|
||||
.clearValue = {0.0, 0.0, 0.0, 0.0},
|
||||
},
|
||||
};
|
||||
const wgpu::RenderPassDescriptor renderPassDescriptor{
|
||||
.label = "Depth Snapshot Pass",
|
||||
.colorAttachmentCount = colorAttachments.size(),
|
||||
.colorAttachments = colorAttachments.data(),
|
||||
.timestampWrites = webgpu::gpu_prof::pass_writes("Depth snapshot"),
|
||||
};
|
||||
const auto pass = cmd.BeginRenderPass(&renderPassDescriptor);
|
||||
pass.SetPipeline(pipeline);
|
||||
pass.SetBindGroup(0, bindGroup);
|
||||
pass.Draw(3);
|
||||
pass.End();
|
||||
}
|
||||
|
||||
} // namespace aurora::gfx::tex_copy_conv
|
||||
|
||||
@@ -26,4 +26,8 @@ void shutdown();
|
||||
void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req);
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req);
|
||||
|
||||
bool snapshot_depth_supported() noexcept;
|
||||
void snapshot_depth(const wgpu::CommandEncoder& cmd, const wgpu::TextureView& srcDepth, uint32_t msaaSamples,
|
||||
const wgpu::TextureView& dst);
|
||||
|
||||
} // namespace aurora::gfx::tex_copy_conv
|
||||
|
||||
@@ -697,7 +697,6 @@ void WebGPURenderInterface::BeginRenderTargetPass(const wgpu::TextureView& view,
|
||||
.stencilLoadOp = clearStencil ? wgpu::LoadOp::Clear : wgpu::LoadOp::Undefined,
|
||||
.stencilStoreOp = clearStencil ? wgpu::StoreOp::Store : wgpu::StoreOp::Undefined,
|
||||
.stencilClearValue = 0,
|
||||
.observable = true,
|
||||
});
|
||||
m_passActive = true;
|
||||
ApplyViewport();
|
||||
@@ -725,7 +724,6 @@ void WebGPURenderInterface::BeginLayerPass(Rml::LayerHandle layer, wgpu::LoadOp
|
||||
.stencilLoadOp = clearStencil ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load,
|
||||
.stencilStoreOp = wgpu::StoreOp::Store,
|
||||
.stencilClearValue = 0,
|
||||
.observable = true,
|
||||
});
|
||||
m_passActive = true;
|
||||
ApplyViewport();
|
||||
|
||||
Reference in New Issue
Block a user