cnq3/code/renderer/grp_main.cpp

690 lines
19 KiB
C++
Raw Normal View History

/*
===========================================================================
2024-01-13 21:40:13 +00:00
Copyright (C) 2022-2024 Gian 'myT' Schellenbaum
This file is part of Challenge Quake 3 (CNQ3).
Challenge Quake 3 is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation; either version 2 of the License,
or (at your option) any later version.
Challenge Quake 3 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Challenge Quake 3. If not, see <https://www.gnu.org/licenses/>.
===========================================================================
*/
// Gameplay Rendering Pipeline - main interface
#include "grp_local.h"
2024-01-13 21:40:13 +00:00
#include "grp_uber_shaders.h"
#include "compshaders/grp/uber_shader.h"
#include "compshaders/grp/complete_uber_vs.h"
#include "compshaders/grp/complete_uber_ps.h"
#include "../client/cl_imgui.h"
2024-01-13 21:40:13 +00:00
namespace ui
{
#include "compshaders/grp/ui_vs.h"
#include "compshaders/grp/ui_ps.h"
}
namespace imgui
{
#include "compshaders/grp/imgui_vs.h"
#include "compshaders/grp/imgui_ps.h"
}
namespace nuklear
{
#include "compshaders/grp/nuklear_vs.h"
#include "compshaders/grp/nuklear_ps.h"
}
namespace mip_1
{
#include "compshaders/grp/mip_1_cs.h"
}
namespace mip_2
{
#include "compshaders/grp/mip_2_cs.h"
}
namespace mip_3
{
#include "compshaders/grp/mip_3_cs.h"
}
GRP grp;
2024-01-13 21:40:13 +00:00
IRenderPipeline* grpp = &grp;
static const ShaderByteCode vertexShaderByteCodes[8] =
{
ShaderByteCode(g_vs_1),
ShaderByteCode(g_vs_2),
ShaderByteCode(g_vs_3),
ShaderByteCode(g_vs_4),
ShaderByteCode(g_vs_5),
ShaderByteCode(g_vs_6),
ShaderByteCode(g_vs_7),
ShaderByteCode(g_vs_8)
};
#define PS(Data) #Data,
static const char* uberPixelShaderStateStrings[] =
{
UBER_SHADER_PS_LIST(PS)
};
#undef PS
#define PS(Data) ShaderByteCode(g_ps_##Data),
static const ShaderByteCode uberPixelShaderByteCodes[] =
{
UBER_SHADER_PS_LIST(PS)
};
#undef PS
#define PS(Data) 1 +
static const uint32_t uberPixelShaderCacheSize = UBER_SHADER_PS_LIST(PS) 0;
#undef PS
static UberPixelShaderState uberPixelShaderStates[uberPixelShaderCacheSize];
static bool IsCommutativeBlendState(unsigned int stateBits)
{
const unsigned int blendStates[] =
{
GLS_SRCBLEND_ONE | GLS_DSTBLEND_ONE, // additive
GLS_SRCBLEND_DST_COLOR | GLS_DSTBLEND_ZERO, // modulate
GLS_SRCBLEND_ZERO | GLS_DSTBLEND_SRC_COLOR, // modulate
GLS_SRCBLEND_SRC_ALPHA | GLS_DSTBLEND_ONE // pre-multiplied alpha blend
};
const unsigned int blendBits = stateBits & GLS_BLEND_BITS;
for(int b = 0; b < ARRAY_LEN(blendStates); ++b)
{
if(blendBits == blendStates[b])
{
return true;
}
}
return false;
}
void GRP::Init()
{
2024-01-13 21:40:13 +00:00
InitDesc initDesc;
initDesc.directDescriptorHeapIndexing = false;
srp.firstInit = RHI::Init(initDesc);
2024-01-13 21:40:13 +00:00
if(srp.firstInit)
{
RootSignatureDesc desc("main");
desc.usingVertexBuffers = true;
desc.samplerCount = ARRAY_LEN(samplers);
desc.samplerVisibility = ShaderStages::PixelBit;
desc.genericVisibility = ShaderStages::PixelBit;
desc.AddRange(DescriptorType::Texture, 0, MAX_DRAWIMAGES * 2);
2023-06-18 00:42:59 +00:00
desc.AddRange(DescriptorType::RWBuffer, MAX_DRAWIMAGES * 2, 1);
rootSignatureDesc = desc;
rootSignature = CreateRootSignature(desc);
descriptorTable = CreateDescriptorTable(DescriptorTableDesc("game textures", rootSignature));
desc.name = "world";
desc.usingVertexBuffers = true;
desc.constants[ShaderStage::Vertex].byteCount = sizeof(WorldVertexRC);
desc.constants[ShaderStage::Pixel].byteCount = sizeof(WorldPixelRC);
desc.samplerVisibility = ShaderStages::PixelBit;
desc.genericVisibility = ShaderStages::VertexBit | ShaderStages::PixelBit;
uberRootSignature = CreateRootSignature(desc);
for(uint32_t i = 0; i < uberPixelShaderCacheSize; ++i)
{
if(!ParseUberPixelShaderState(uberPixelShaderStates[i], uberPixelShaderStateStrings[i]))
{
Q_assert(!"ParseUberPixelShaderState failed!");
}
}
2024-01-13 21:40:13 +00:00
srp.CreateShaderTraceBuffers();
DescriptorTableUpdate update;
update.SetRWBuffers(1, &srp.traceRenderBuffer, MAX_DRAWIMAGES * 2);
UpdateDescriptorTable(descriptorTable, update);
}
// we recreate the samplers on every vid_restart to create the right level
// of anisotropy based on the latched CVar
for(uint32_t w = 0; w < TW_COUNT; ++w)
{
for(uint32_t f = 0; f < TextureFilter::Count; ++f)
{
for(uint32_t m = 0; m < MaxTextureMips; ++m)
{
const textureWrap_t wrap = (textureWrap_t)w;
const TextureFilter::Id filter = (TextureFilter::Id)f;
2024-01-13 21:40:13 +00:00
const uint32_t s = GetBaseSamplerIndex(wrap, filter, m);
SamplerDesc desc(wrap, filter, (float)m);
desc.shortLifeTime = true;
samplers[s] = CreateSampler(desc);
}
}
}
// update our descriptor table with the new sampler descriptors
{
DescriptorTableUpdate update;
update.SetSamplers(ARRAY_LEN(samplers), samplers);
UpdateDescriptorTable(descriptorTable, update);
}
textureIndex = 0;
psoCount = 1; // we treat index 0 as invalid
{
switch(r_rtColorFormat->integer)
{
case RTCF_R10G10B10A2:
renderTargetFormat = TextureFormat::R10G10B10A2_UNorm;
break;
case RTCF_R16G16B16A16:
renderTargetFormat = TextureFormat::RGBA64_UNorm;
break;
case RTCF_R8G8B8A8:
default:
renderTargetFormat = TextureFormat::RGBA32_UNorm;
break;
}
TextureDesc desc("render target", glConfig.vidWidth, glConfig.vidHeight);
desc.initialState = ResourceStates::RenderTargetBit;
desc.allowedState = ResourceStates::RenderTargetBit | ResourceStates::PixelShaderAccessBit;
Vector4Clear(desc.clearColor);
desc.usePreferredClearValue = true;
desc.committedResource = true;
desc.format = renderTargetFormat;
desc.shortLifeTime = true;
renderTarget = RHI::CreateTexture(desc);
}
{
TextureDesc desc("readback render target", glConfig.vidWidth, glConfig.vidHeight);
desc.initialState = ResourceStates::RenderTargetBit;
desc.allowedState = ResourceStates::RenderTargetBit | ResourceStates::PixelShaderAccessBit;
Vector4Clear(desc.clearColor);
desc.usePreferredClearValue = true;
desc.committedResource = true;
desc.format = TextureFormat::RGBA32_UNorm;
desc.shortLifeTime = true;
readbackRenderTarget = RHI::CreateTexture(desc);
}
2024-01-13 21:40:13 +00:00
ui.Init(false, ShaderByteCode(ui::g_vs), ShaderByteCode(ui::g_ps), renderTargetFormat, descriptorTable, &rootSignatureDesc);
world.Init();
2024-01-13 21:40:13 +00:00
mipMapGen.Init(false, ShaderByteCode(mip_1::g_cs), ShaderByteCode(mip_2::g_cs), ShaderByteCode(mip_3::g_cs));
const HTexture fontAtlas = imgui.Init(false, ShaderByteCode(imgui::g_vs), ShaderByteCode(imgui::g_ps), renderTargetFormat, descriptorTable, &rootSignatureDesc);
const uint32_t fontAtlasSRV = RegisterTexture(fontAtlas);
imgui.RegisterFontAtlas(fontAtlasSRV);
nuklear.Init(false, ShaderByteCode(nuklear::g_vs), ShaderByteCode(nuklear::g_ps), renderTargetFormat, descriptorTable, &rootSignatureDesc);
post.Init();
post.SetToneMapInput(renderTarget);
smaa.Init(); // must be after post
2024-01-13 21:40:13 +00:00
srp.firstInit = false;
}
void GRP::ShutDown(bool fullShutDown)
{
RHI::ShutDown(fullShutDown);
}
void GRP::BeginFrame()
{
2024-01-13 21:40:13 +00:00
srp.psoCount = psoCount;
srp.psoChangeCount = world.psoChangeCount;
srp.psoStatsValid = true;
2024-01-13 21:40:13 +00:00
srp.BeginFrame();
smaa.Update();
// have it be first to we can use ImGUI in the other components too
2024-01-13 21:40:13 +00:00
grp.imgui.BeginFrame();
RHI::BeginFrame();
ui.BeginFrame();
world.BeginFrame();
nuklear.BeginFrame();
const float clearColor[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
const TextureBarrier barrier(renderTarget, ResourceStates::RenderTargetBit);
CmdBarrier(1, &barrier);
CmdClearColorTarget(renderTarget, clearColor);
// nothing is bound to the command list yet!
2024-01-13 21:40:13 +00:00
srp.renderMode = RenderMode::None;
frameSeed = (float)rand() / (float)RAND_MAX;
}
void GRP::EndFrame()
{
2024-01-13 21:40:13 +00:00
srp.DrawGUI();
imgui.Draw(renderTarget);
post.Draw("Post-process", GetSwapChainTexture());
2023-06-18 00:42:59 +00:00
world.EndFrame();
UpdateReadbackTexture();
2024-01-13 21:40:13 +00:00
srp.EndFrame();
}
void GRP::UpdateReadbackTexture()
{
if(!updateReadbackTexture)
{
return;
}
post.Draw("Readback post-process", readbackRenderTarget);
}
void GRP::CreateTexture(image_t* image, int mipCount, int width, int height)
{
TextureDesc desc(image->name, width, height, mipCount);
desc.committedResource = width * height >= (1 << 20);
desc.shortLifeTime = true;
if(mipCount > 1)
{
desc.allowedState |= ResourceStates::UnorderedAccessBit; // for mip-map generation
}
image->texture = ::RHI::CreateTexture(desc);
image->textureIndex = RegisterTexture(image->texture);
}
void GRP::UpoadTextureAndGenerateMipMaps(image_t* image, const byte* data)
{
MappedTexture texture;
RHI::BeginTextureUpload(texture, image->texture);
for(uint32_t r = 0; r < texture.rowCount; ++r)
{
memcpy(texture.mappedData + r * texture.dstRowByteCount, data + r * texture.srcRowByteCount, texture.srcRowByteCount);
}
RHI::EndTextureUpload();
mipMapGen.GenerateMipMaps(image->texture);
}
void GRP::BeginTextureUpload(MappedTexture& mappedTexture, image_t* image)
{
RHI::BeginTextureUpload(mappedTexture, image->texture);
}
void GRP::EndTextureUpload()
{
RHI::EndTextureUpload();
}
void GRP::ProcessWorld(world_t& world_)
{
world.ProcessWorld(world_);
}
void GRP::ProcessModel(model_t& model)
{
// @TODO: !!!
//__debugbreak();
}
void GRP::ProcessShader(shader_t& shader)
{
shader.numPipelines = 0;
if(shader.numStages < 1)
{
return;
}
// @TODO: GLS_POLYMODE_LINE
const bool clampDepth = r_depthClamp->integer != 0 || shader.isSky;
if(shader.isOpaque)
{
Q_assert(IsDepthFadeEnabled(shader) == false);
// @TODO: fix up cache.stageStateBits[0] based on depth state from follow-up states
CachedPSO cache = {};
cache.desc.depthFade = false;
cache.desc.polygonOffset = !!shader.polygonOffset;
cache.desc.clampDepth = clampDepth;
cache.stageStateBits[0] = shader.stages[0]->stateBits & (~GLS_POLYMODE_LINE);
for(int s = 1; s < shader.numStages; ++s)
{
cache.stageStateBits[s] = shader.stages[s]->stateBits & (GLS_BLEND_BITS | GLS_ATEST_BITS);
}
cache.stageCount = shader.numStages;
2023-07-08 20:32:04 +00:00
cache.desc.cullType = shader.cullType;
shader.pipelines[0].pipeline = CreatePSO(cache, shader.name);
2023-07-08 20:32:04 +00:00
cache.desc.cullType = GetMirrorredCullType(shader.cullType);
shader.pipelines[0].mirrorPipeline = CreatePSO(cache, va("%s mirror", shader.name));
shader.pipelines[0].firstStage = 0;
shader.pipelines[0].numStages = shader.numStages;
shader.numPipelines = 1;
}
else
{
CachedPSO cache = {};
2023-07-08 20:32:04 +00:00
cache.desc.depthFade = IsDepthFadeEnabled(shader);
cache.desc.polygonOffset = !!shader.polygonOffset;
cache.desc.clampDepth = clampDepth;
cache.stageCount = 0;
unsigned int prevStateBits = 0xFFFFFFFF;
int firstStage = 0;
for(int s = 0; s < shader.numStages; ++s)
{
const unsigned int currStateBits = shader.stages[s]->stateBits & (~GLS_POLYMODE_LINE);
if(cache.stageCount > 0)
{
// we could combine AT/DW in some circumstances, but we don't care to for now
const bool cantCombine = (shader.stages[s]->stateBits & (GLS_ATEST_BITS | GLS_DEPTHMASK_TRUE)) != 0;
if(currStateBits == prevStateBits &&
!cantCombine &&
IsCommutativeBlendState(currStateBits))
{
cache.stageStateBits[cache.stageCount++] = currStateBits;
}
else
{
pipeline_t& p = shader.pipelines[shader.numPipelines++];
2023-07-08 20:32:04 +00:00
cache.desc.cullType = shader.cullType;
p.pipeline = CreatePSO(cache, va("%s #%d", shader.name, shader.numPipelines));
2023-07-08 20:32:04 +00:00
cache.desc.cullType = GetMirrorredCullType(shader.cullType);
p.mirrorPipeline = CreatePSO(cache, va("%s #%d mirror", shader.name, shader.numPipelines));
p.firstStage = firstStage;
p.numStages = cache.stageCount;
cache.stageStateBits[0] = currStateBits;
cache.stageCount = 1;
firstStage = s;
}
}
else
{
cache.stageStateBits[0] = currStateBits;
cache.stageCount = 1;
}
prevStateBits = currStateBits;
}
if(cache.stageCount > 0)
{
pipeline_t& p = shader.pipelines[shader.numPipelines++];
2023-07-08 20:32:04 +00:00
cache.desc.cullType = shader.cullType;
p.pipeline = CreatePSO(cache, va("%s #%d", shader.name, shader.numPipelines));
2023-07-08 20:32:04 +00:00
cache.desc.cullType = GetMirrorredCullType(shader.cullType);
p.mirrorPipeline = CreatePSO(cache, va("%s #%d mirror", shader.name, shader.numPipelines));
p.firstStage = firstStage;
p.numStages = cache.stageCount;
}
}
}
uint32_t GRP::RegisterTexture(HTexture htexture)
{
const uint32_t index = textureIndex++;
DescriptorTableUpdate update;
update.SetTextures(1, &htexture, index);
UpdateDescriptorTable(descriptorTable, update);
return index;
}
uint32_t GRP::CreatePSO(CachedPSO& cache, const char* name)
{
Q_assert(cache.stageCount > 0);
const uint32_t pixelShaderStateBits = GLS_BLEND_BITS | GLS_ATEST_BITS;
for(uint32_t p = 1; p < psoCount; ++p)
{
if(cache.stageCount == psos[p].stageCount &&
memcmp(&cache.desc, &psos[p].desc, sizeof(cache.desc)) == 0 &&
memcmp(&cache.stageStateBits, &psos[p].stageStateBits, cache.stageCount * sizeof(cache.stageStateBits[0])) == 0)
{
return p;
}
}
Q_assert(psoCount < ARRAY_LEN(psos));
#if defined(_DEBUG)
Q_strncpyz(cache.name, name, sizeof(cache.name));
#endif
int uberPixelShaderIndex = -1;
for(uint32_t i = 0; i < uberPixelShaderCacheSize; ++i)
{
const UberPixelShaderState& state = uberPixelShaderStates[i];
const int dither = (state.globalState & UBERPS_DITHER_BIT) != 0 ? 1 : 0;
const bool depthFade = (state.globalState & UBERPS_DEPTHFADE_BIT) != 0;
if(cache.stageCount != (uint32_t)state.stageCount ||
r_dither->integer != dither ||
cache.desc.depthFade != depthFade)
{
continue;
}
bool found = true;
for(uint32_t s = 0; s < cache.stageCount; ++s)
{
const uint32_t psoCacheState = cache.stageStateBits[s] & pixelShaderStateBits;
const uint32_t psCacheState = (uint32_t)state.stageStates[s] & pixelShaderStateBits;
if(psoCacheState != psCacheState)
{
found = false;
break;
}
}
if(found)
{
uberPixelShaderIndex = (int)i;
break;
}
}
HShader pixelShader = RHI_MAKE_NULL_HANDLE();
ShaderByteCode pixelShaderByteCode;
if(uberPixelShaderIndex < 0)
{
uint32_t macroCount = 0;
ShaderMacro macros[64];
2024-01-13 21:40:13 +00:00
macros[macroCount].name = "DISABLE_PRAGMA_ONCE";
macros[macroCount].value = "1";
macroCount++;
macros[macroCount].name = "STAGE_COUNT";
macros[macroCount].value = va("%d", cache.stageCount);
macroCount++;
if(r_dither->integer)
{
macros[macroCount].name = "DITHER";
macros[macroCount].value = "1";
macroCount++;
}
if(cache.desc.depthFade)
{
macros[macroCount].name = "DEPTH_FADE";
macros[macroCount].value = "1";
macroCount++;
}
for(int s = 0; s < cache.stageCount; ++s)
{
macros[macroCount].name = va("STAGE%d_BITS", s);
macros[macroCount].value = va("%d", (int)cache.stageStateBits[s] & pixelShaderStateBits);
macroCount++;
}
Q_assert(macroCount <= ARRAY_LEN(macros));
pixelShader = CreateShader(ShaderDesc(ShaderStage::Pixel, sizeof(uber_shader_string), uber_shader_string, "ps", macroCount, macros));
pixelShaderByteCode = GetShaderByteCode(pixelShader);
}
else
{
pixelShaderByteCode = uberPixelShaderByteCodes[uberPixelShaderIndex];
}
// important missing entries can be copy-pasted into UBER_SHADER_PS_LIST
#if 0
if(uberPixelShaderIndex < 0)
{
unsigned int flags = 0;
if(r_dither->integer)
{
flags |= UBERPS_DITHER_BIT;
}
if(cache.desc.depthFade)
{
flags |= UBERPS_DEPTHFADE_BIT;
}
Sys_DebugPrintf("\tshader: %s\n", name);
ri.Printf(PRINT_ALL, "^2 shader: %s\n", name);
Sys_DebugPrintf("\tPS(%d_%X", (int)cache.stageCount, flags);
ri.Printf(PRINT_ALL, " PS(%d_%X", (int)cache.stageCount, flags);
for(int s = 0; s < cache.stageCount; ++s)
{
Sys_DebugPrintf("_%X", (unsigned int)(cache.stageStateBits[s] & pixelShaderStateBits));
ri.Printf(PRINT_ALL, "_%X", (unsigned int)(cache.stageStateBits[s] & pixelShaderStateBits));
}
Sys_DebugPrintf(") \\\n");
ri.Printf(PRINT_ALL, ") \\\n");
}
#endif
uint32_t a = 0;
GraphicsPipelineDesc desc(name, uberRootSignature);
desc.shortLifeTime = true; // the PSO cache is only valid for this map!
desc.vertexShader = vertexShaderByteCodes[cache.stageCount - 1];
desc.pixelShader = pixelShaderByteCode;
desc.vertexLayout.AddAttribute(a++, ShaderSemantic::Position, DataType::Float32, 3, 0);
desc.vertexLayout.AddAttribute(a++, ShaderSemantic::Normal, DataType::Float32, 2, 0);
for(int s = 0; s < cache.stageCount; ++s)
{
desc.vertexLayout.AddAttribute(a++, ShaderSemantic::TexCoord, DataType::Float32, 2, 0);
desc.vertexLayout.AddAttribute(a++, ShaderSemantic::Color, DataType::UNorm8, 4, 0);
}
if(cache.desc.depthFade)
{
desc.depthStencil.DisableDepth();
}
else
{
desc.depthStencil.depthStencilFormat = TextureFormat::Depth32_Float;
desc.depthStencil.depthComparison =
(cache.stageStateBits[0] & GLS_DEPTHFUNC_EQUAL) != 0 ?
ComparisonFunction::Equal :
ComparisonFunction::GreaterEqual;
desc.depthStencil.enableDepthTest = (cache.stageStateBits[0] & GLS_DEPTHTEST_DISABLE) == 0;
desc.depthStencil.enableDepthWrites = (cache.stageStateBits[0] & GLS_DEPTHMASK_TRUE) != 0;
}
desc.rasterizer.cullMode = cache.desc.cullType;
desc.rasterizer.polygonOffset = cache.desc.polygonOffset;
desc.rasterizer.clampDepth = cache.desc.clampDepth;
desc.AddRenderTarget(cache.stageStateBits[0] & GLS_BLEND_BITS, renderTargetFormat);
cache.pipeline = CreateGraphicsPipeline(desc);
if(uberPixelShaderIndex < 0)
{
DestroyShader(pixelShader);
}
const uint32_t index = psoCount++;
psos[index] = cache;
return index;
}
void GRP::ExecuteRenderCommands(const byte* data, bool readbackRequested)
{
updateReadbackTexture = readbackRequested;
for(;;)
{
const int commandId = ((const renderCommandBase_t*)data)->commandId;
if(commandId < 0 || commandId >= RC_COUNT)
{
assert(!"Invalid render command type");
return;
}
if(commandId == RC_END_OF_LIST)
{
return;
}
switch(commandId)
{
case RC_UI_SET_COLOR:
2024-01-13 21:40:13 +00:00
ui.CmdSetColor(*(const uiSetColorCommand_t*)data);
break;
case RC_UI_DRAW_QUAD:
2024-01-13 21:40:13 +00:00
ui.CmdDrawQuad(*(const uiDrawQuadCommand_t*)data);
break;
case RC_UI_DRAW_TRIANGLE:
2024-01-13 21:40:13 +00:00
ui.CmdDrawTriangle(*(const uiDrawTriangleCommand_t*)data);
break;
case RC_DRAW_SCENE_VIEW:
2024-01-13 21:40:13 +00:00
world.DrawSceneView(*(const drawSceneViewCommand_t*)data);
break;
case RC_BEGIN_FRAME:
BeginFrame();
break;
case RC_SWAP_BUFFERS:
EndFrame();
break;
case RC_BEGIN_UI:
2024-01-13 21:40:13 +00:00
ui.Begin(renderTarget);
break;
case RC_END_UI:
ui.End();
break;
case RC_BEGIN_3D:
world.Begin();
break;
case RC_END_3D:
world.End();
break;
case RC_END_SCENE:
smaa.Draw(((const endSceneCommand_t*)data)->viewParms);
break;
case RC_BEGIN_NK:
2024-01-13 21:40:13 +00:00
nuklear.Begin(renderTarget);
break;
case RC_END_NK:
nuklear.End();
break;
case RC_NK_UPLOAD:
nuklear.Upload(*(const nuklearUploadCommand_t*)data);
break;
case RC_NK_DRAW:
nuklear.Draw(*(const nuklearDrawCommand_t*)data);
break;
default:
Q_assert(!"Unsupported render command type");
return;
}
data += renderCommandSizes[commandId];
}
}
void GRP::ReadPixels(int w, int h, int alignment, colorSpace_t colorSpace, void* outPixels)
{
2024-01-13 21:40:13 +00:00
ReadTextureImage(outPixels, readbackRenderTarget, w, h, alignment, colorSpace);
}