mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-11 05:38:31 +09:00
[Test, Bench] (ShaderTranspiler): pin L1 backend-agnosticism and measure the whole-front-end hit
This commit is contained in:
@@ -37,6 +37,7 @@
|
|||||||
#include "Init.h"
|
#include "Init.h"
|
||||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||||
#include "MG_State/GLState/Core.h"
|
#include "MG_State/GLState/Core.h"
|
||||||
|
#include "MG_State/GLState/ProgramState/ProgramTranslationCache.h"
|
||||||
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
||||||
#include "MG_Util/ShaderTranspiler/SpvcSession.h"
|
#include "MG_Util/ShaderTranspiler/SpvcSession.h"
|
||||||
#include "MG_Util/ShaderTranspiler/TranslationCache.h"
|
#include "MG_Util/ShaderTranspiler/TranslationCache.h"
|
||||||
@@ -220,16 +221,91 @@ static void BM_ProgramLink_CacheOn(benchmark::State& state) {
|
|||||||
const String vs = kVertexSource;
|
const String vs = kVertexSource;
|
||||||
const String fs = SwizzleLikeFragment("", static_cast<int>(state.range(0)));
|
const String fs = SwizzleLikeFragment("", static_cast<int>(state.range(0)));
|
||||||
LinkOneProgram(vs, fs); // prime, so the measured loop is the steady state
|
LinkOneProgram(vs, fs); // prime, so the measured loop is the steady state
|
||||||
const TranslationCacheStats before = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
for (auto _ : state) {
|
for (auto _ : state) {
|
||||||
LinkOneProgram(vs, fs);
|
LinkOneProgram(vs, fs);
|
||||||
}
|
}
|
||||||
const TranslationCacheStats stats = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats stats = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
|
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
|
||||||
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
|
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
|
||||||
}
|
}
|
||||||
BENCHMARK(BM_ProgramLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
|
BENCHMARK(BM_ProgramLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// L1, the shape the memo actually exists for: MANY PROGRAMS OUT OF THE SAME SHADERS.
|
||||||
|
//
|
||||||
|
// The pair above deletes its shader objects every iteration, which forces a fresh glslang
|
||||||
|
// parse per iteration no matter what the link does - glCompileShader parses, and that is a
|
||||||
|
// DIFFERENT entry point from the one L1 memoizes. It is a real workload (what an application
|
||||||
|
// that never reuses a shader object pays) but it is the pessimistic one, and the residual it
|
||||||
|
// leaves is the parse, not the link.
|
||||||
|
//
|
||||||
|
// This pair keeps the shader objects alive, so ShaderCompileAdoptionMap can hand a later
|
||||||
|
// glCompileShader the earlier one's parse, and the L1 hit then skips the link, mapIO, the
|
||||||
|
// SPIR-V, the reflection and the routing outright. That is the
|
||||||
|
// KHR-GL33.texture_swizzle.smoke_* shape - 2592 programs over a handful of distinct sources.
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
namespace {
|
||||||
|
struct SharedShaders {
|
||||||
|
GLuint vs = 0;
|
||||||
|
GLuint fs = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
SharedShaders MakeSharedShaders(const String& vertexSource, const String& fragmentSource) {
|
||||||
|
using namespace MG_Impl::GLImpl;
|
||||||
|
SharedShaders shaders;
|
||||||
|
shaders.vs = CreateShader(GL_VERTEX_SHADER);
|
||||||
|
const char* vsText = vertexSource.c_str();
|
||||||
|
ShaderSource(shaders.vs, 1, &vsText, nullptr);
|
||||||
|
CompileShader(shaders.vs);
|
||||||
|
shaders.fs = CreateShader(GL_FRAGMENT_SHADER);
|
||||||
|
const char* fsText = fragmentSource.c_str();
|
||||||
|
ShaderSource(shaders.fs, 1, &fsText, nullptr);
|
||||||
|
CompileShader(shaders.fs);
|
||||||
|
return shaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LinkFromSharedShaders(const SharedShaders& shaders) {
|
||||||
|
using namespace MG_Impl::GLImpl;
|
||||||
|
const GLuint program = CreateProgram();
|
||||||
|
AttachShader(program, shaders.vs);
|
||||||
|
AttachShader(program, shaders.fs);
|
||||||
|
LinkProgram(program);
|
||||||
|
benchmark::DoNotOptimize(program);
|
||||||
|
DeleteProgram(program);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
static void BM_SharedShaderLink_CacheOff(benchmark::State& state) {
|
||||||
|
MobileGL::Initialize();
|
||||||
|
const SyncCompileScope sync;
|
||||||
|
const CacheModeScope cache(false);
|
||||||
|
const SharedShaders shaders =
|
||||||
|
MakeSharedShaders(kVertexSource, SwizzleLikeFragment("", static_cast<int>(state.range(0))));
|
||||||
|
for (auto _ : state) {
|
||||||
|
LinkFromSharedShaders(shaders);
|
||||||
|
}
|
||||||
|
state.SetLabel("MOBILEGL_SHADER_CACHE=0");
|
||||||
|
}
|
||||||
|
BENCHMARK(BM_SharedShaderLink_CacheOff)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
|
||||||
|
|
||||||
|
static void BM_SharedShaderLink_CacheOn(benchmark::State& state) {
|
||||||
|
MobileGL::Initialize();
|
||||||
|
const SyncCompileScope sync;
|
||||||
|
const CacheModeScope cache(true);
|
||||||
|
const SharedShaders shaders =
|
||||||
|
MakeSharedShaders(kVertexSource, SwizzleLikeFragment("", static_cast<int>(state.range(0))));
|
||||||
|
LinkFromSharedShaders(shaders); // prime, so the measured loop is the steady state
|
||||||
|
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
for (auto _ : state) {
|
||||||
|
LinkFromSharedShaders(shaders);
|
||||||
|
}
|
||||||
|
const TranslationCacheStats stats = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
state.counters["L1_hits"] = static_cast<double>(stats.hits - before.hits);
|
||||||
|
state.counters["L1_misses"] = static_cast<double>(stats.misses - before.misses);
|
||||||
|
}
|
||||||
|
BENCHMARK(BM_SharedShaderLink_CacheOn)->Arg(0)->Arg(120)->Unit(benchmark::kMicrosecond);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// L2, component: the DirectGLES SPIR-V pass chain plus SPIRV-Cross for one stage.
|
// L2, component: the DirectGLES SPIR-V pass chain plus SPIRV-Cross for one stage.
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
#include "Init.h"
|
#include "Init.h"
|
||||||
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
#include "MG_Impl/GLImpl/Program/GL_Program.h"
|
||||||
#include "MG_State/GLState/Core.h"
|
#include "MG_State/GLState/Core.h"
|
||||||
|
#include "MG_State/GLState/ProgramState/ProgramTranslationCache.h"
|
||||||
#include "MG_Util/ShaderTranspiler/CompileEnv.h"
|
#include "MG_Util/ShaderTranspiler/CompileEnv.h"
|
||||||
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
|
||||||
#include "MG_Util/ShaderTranspiler/SpvcSession.h"
|
#include "MG_Util/ShaderTranspiler/SpvcSession.h"
|
||||||
@@ -125,6 +126,8 @@ void main() {
|
|||||||
inputs.stages = stages;
|
inputs.stages = stages;
|
||||||
inputs.shaderCompileFlags = 0;
|
inputs.shaderCompileFlags = 0;
|
||||||
inputs.enableSpirvValidation = false;
|
inputs.enableSpirvValidation = false;
|
||||||
|
inputs.xfbBufferMode = GL_INTERLEAVED_ATTRIBS;
|
||||||
|
inputs.maxFragmentOutputColorNumber = 8;
|
||||||
return inputs;
|
return inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +160,42 @@ void main() {
|
|||||||
return digest;
|
return digest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything the GL query surface says about a linked program, as one string. Used to
|
||||||
|
// assert that a program served from the L1 memo - which never built a TProgram - answers
|
||||||
|
// identically to the one that was parsed.
|
||||||
|
String ReflectionFingerprint(const GLuint program) {
|
||||||
|
const auto& object = MG_State::pGLContext->GetProgramObject(program);
|
||||||
|
if (!object) return String();
|
||||||
|
String out;
|
||||||
|
const Uint uniformCount = object->GetUniformCount();
|
||||||
|
for (Uint i = 0; i < uniformCount; ++i) {
|
||||||
|
out += object->GetActiveUniformName(i);
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformType(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformArraySize(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformBlockIndex(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformOffset(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformArrayStride(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformMatrixStride(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveUniformIsRowMajor(i));
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
const Int attribCount = object->GetActiveAttributesCount();
|
||||||
|
for (Int i = 0; i < attribCount; ++i) {
|
||||||
|
out += object->GetActiveAttribName(static_cast<Uint>(i));
|
||||||
|
out += ':' + std::to_string(object->GetActiveAttribType(static_cast<Uint>(i)));
|
||||||
|
out += ':' + std::to_string(object->GetActiveAttribArraySize(static_cast<Uint>(i)));
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
const Int outputCount = object->GetActiveFragmentOutputCount();
|
||||||
|
for (Int i = 0; i < outputCount; ++i) {
|
||||||
|
out += object->GetActiveFragmentOutputName(static_cast<Uint>(i));
|
||||||
|
out += ':' + std::to_string(object->GetFragmentOutputLocation(static_cast<Uint>(i)));
|
||||||
|
out += ':' + std::to_string(object->GetFragmentOutputType(static_cast<Uint>(i)));
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
GLuint MakeShader(const GLenum type, const String& source) {
|
GLuint MakeShader(const GLenum type, const String& source) {
|
||||||
const GLuint shader = MG_Impl::GLImpl::CreateShader(type);
|
const GLuint shader = MG_Impl::GLImpl::CreateShader(type);
|
||||||
const char* text = source.c_str();
|
const char* text = source.c_str();
|
||||||
@@ -383,6 +422,8 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
|
|||||||
const UnorderedMap<String, Uint> fragOut{{"fragColor", 1u}};
|
const UnorderedMap<String, Uint> fragOut{{"fragColor", 1u}};
|
||||||
const UnorderedMap<String, Uint> fragIndex{{"fragColor", 1u}};
|
const UnorderedMap<String, Uint> fragIndex{{"fragColor", 1u}};
|
||||||
const UnorderedMap<String, Uint> opaque{{"uTex", 5u}};
|
const UnorderedMap<String, Uint> opaque{{"uTex", 5u}};
|
||||||
|
const Vector<String> xfbVaryings{"vPos", "gl_NextBuffer", "vUv"};
|
||||||
|
const Vector<String> xfbVaryingsReordered{"vUv", "gl_NextBuffer", "vPos"};
|
||||||
|
|
||||||
const SpirvTranslationKeyInputs base = BaselineSpirvInputs(baseStages);
|
const SpirvTranslationKeyInputs base = BaselineSpirvInputs(baseStages);
|
||||||
const TranslationCacheKey baseKey = BuildSpirvTranslationKey(base);
|
const TranslationCacheKey baseKey = BuildSpirvTranslationKey(base);
|
||||||
@@ -450,6 +491,30 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
|
|||||||
v.enableSpirvValidation = true;
|
v.enableSpirvValidation = true;
|
||||||
variants.emplace_back("enableSpirvValidation", BuildSpirvTranslationKey(v));
|
variants.emplace_back("enableSpirvValidation", BuildSpirvTranslationKey(v));
|
||||||
}
|
}
|
||||||
|
// ---- inputs the WIDENED payload pulled into the key ----
|
||||||
|
// They cannot move a word of the generated SPIR-V, but they do shape the reflection the
|
||||||
|
// payload now carries, so they have to split the key. This is the group that would go
|
||||||
|
// stale first if the payload ever grew again without the key following it.
|
||||||
|
{ // glTransformFeedbackVaryings: shapes xfbVaryings / xfbStrides / gsStripTriangles
|
||||||
|
SpirvTranslationKeyInputs v = base;
|
||||||
|
v.requestedXfbVaryings = &xfbVaryings;
|
||||||
|
variants.emplace_back("requestedXfbVaryings", BuildSpirvTranslationKey(v));
|
||||||
|
}
|
||||||
|
{ // ... and its ORDER, which gl_NextBuffer / gl_SkipComponentsN make load-bearing
|
||||||
|
SpirvTranslationKeyInputs v = base;
|
||||||
|
v.requestedXfbVaryings = &xfbVaryingsReordered;
|
||||||
|
variants.emplace_back("requestedXfbVaryings order", BuildSpirvTranslationKey(v));
|
||||||
|
}
|
||||||
|
{ // GL_INTERLEAVED_ATTRIBS vs GL_SEPARATE_ATTRIBS
|
||||||
|
SpirvTranslationKeyInputs v = base;
|
||||||
|
v.xfbBufferMode = GL_SEPARATE_ATTRIBS;
|
||||||
|
variants.emplace_back("xfbBufferMode", BuildSpirvTranslationKey(v));
|
||||||
|
}
|
||||||
|
{ // GL_MAX_DRAW_BUFFERS: decides whether the link is REJECTED at all
|
||||||
|
SpirvTranslationKeyInputs v = base;
|
||||||
|
v.maxFragmentOutputColorNumber = 4;
|
||||||
|
variants.emplace_back("maxFragmentOutputColorNumber", BuildSpirvTranslationKey(v));
|
||||||
|
}
|
||||||
|
|
||||||
for (const auto& [name, key] : variants) {
|
for (const auto& [name, key] : variants) {
|
||||||
EXPECT_FALSE(key == baseKey) << "moving " << name << " did not move the L1 key";
|
EXPECT_FALSE(key == baseKey) << "moving " << name << " did not move the L1 key";
|
||||||
@@ -578,12 +643,12 @@ TEST_F(TranslationCacheTest, L1MemoizesASecondProgramWithIdenticalSources) {
|
|||||||
const CacheModeScope cacheOn(true);
|
const CacheModeScope cacheOn(true);
|
||||||
|
|
||||||
const String fs = SwizzleLikeFragment("");
|
const String fs = SwizzleLikeFragment("");
|
||||||
const TranslationCacheStats before = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
|
||||||
const GLuint first = LinkProgramFromSources(kVertexSource, fs);
|
const GLuint first = LinkProgramFromSources(kVertexSource, fs);
|
||||||
const TranslationCacheStats afterFirst = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats afterFirst = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
const GLuint second = LinkProgramFromSources(kVertexSource, fs);
|
const GLuint second = LinkProgramFromSources(kVertexSource, fs);
|
||||||
const TranslationCacheStats afterSecond = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats afterSecond = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
|
||||||
GLint firstStatus = GL_FALSE;
|
GLint firstStatus = GL_FALSE;
|
||||||
GLint secondStatus = GL_FALSE;
|
GLint secondStatus = GL_FALSE;
|
||||||
@@ -601,6 +666,42 @@ TEST_F(TranslationCacheTest, L1MemoizesASecondProgramWithIdenticalSources) {
|
|||||||
const Vector<Uint64> secondDigest = ProgramSpirvDigest(second);
|
const Vector<Uint64> secondDigest = ProgramSpirvDigest(second);
|
||||||
ASSERT_EQ(firstDigest.size(), 2u);
|
ASSERT_EQ(firstDigest.size(), 2u);
|
||||||
EXPECT_EQ(firstDigest, secondDigest);
|
EXPECT_EQ(firstDigest, secondDigest);
|
||||||
|
|
||||||
|
// The payload is the WHOLE front end, so the reflection has to survive it too - the
|
||||||
|
// program served from the memo never built a TProgram to answer these from.
|
||||||
|
EXPECT_EQ(ReflectionFingerprint(first), ReflectionFingerprint(second));
|
||||||
|
EXPECT_FALSE(ReflectionFingerprint(second).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hit publishes a program that never had a glslang::TProgram at all. Everything GL can ask
|
||||||
|
// about it has to come out of the payload; if any accessor still needed the parse this would
|
||||||
|
// answer differently from the program that was parsed.
|
||||||
|
TEST_F(TranslationCacheTest, AProgramServedFromTheMemoAnswersTheWholeQuerySurface) {
|
||||||
|
const SyncCompileScope sync;
|
||||||
|
const CacheModeScope cacheOn(true);
|
||||||
|
const String fs = SwizzleLikeFragment("");
|
||||||
|
|
||||||
|
const GLuint parsed = LinkProgramFromSources(kVertexSource, fs);
|
||||||
|
const TranslationCacheStats afterFirst = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
const GLuint fromMemo = LinkProgramFromSources(kVertexSource, fs);
|
||||||
|
const TranslationCacheStats afterSecond = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
ASSERT_EQ(afterSecond.hits - afterFirst.hits, 1u) << "the second link was not a hit";
|
||||||
|
|
||||||
|
const auto& parsedObject = MG_State::pGLContext->GetProgramObject(parsed);
|
||||||
|
const auto& memoObject = MG_State::pGLContext->GetProgramObject(fromMemo);
|
||||||
|
ASSERT_NE(parsedObject, nullptr);
|
||||||
|
ASSERT_NE(memoObject, nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(ReflectionFingerprint(parsed), ReflectionFingerprint(fromMemo));
|
||||||
|
EXPECT_EQ(parsedObject->GetUniformCount(), memoObject->GetUniformCount());
|
||||||
|
EXPECT_EQ(parsedObject->GetActiveAttributesCount(), memoObject->GetActiveAttributesCount());
|
||||||
|
EXPECT_EQ(parsedObject->GetActiveFragmentOutputCount(), memoObject->GetActiveFragmentOutputCount());
|
||||||
|
EXPECT_EQ(parsedObject->GetActiveUniformBlocksCount(), memoObject->GetActiveUniformBlocksCount());
|
||||||
|
// The uniform shadow (phase B's half of the payload) has to arrive as well.
|
||||||
|
for (Uint location = 0; location <= parsedObject->GetMaxUniformLocation(); ++location) {
|
||||||
|
EXPECT_EQ(parsedObject->GetUniformOffset(location), memoObject->GetUniformOffset(location))
|
||||||
|
<< "uniform offset at location " << location;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The modules a hit hands out must be the modules a from-scratch translation would have
|
// The modules a hit hands out must be the modules a from-scratch translation would have
|
||||||
@@ -629,9 +730,9 @@ TEST_F(TranslationCacheTest, L1DoesNotMemoizeAcrossDifferentSources) {
|
|||||||
|
|
||||||
// Prime with one, then link a different one: a miss, not a hit.
|
// Prime with one, then link a different one: a miss, not a hit.
|
||||||
(void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment(""));
|
(void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment(""));
|
||||||
const TranslationCacheStats before = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
(void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment("i"));
|
(void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment("i"));
|
||||||
const TranslationCacheStats after = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats after = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
|
||||||
EXPECT_EQ(after.hits - before.hits, 0u);
|
EXPECT_EQ(after.hits - before.hits, 0u);
|
||||||
EXPECT_EQ(after.misses - before.misses, 1u);
|
EXPECT_EQ(after.misses - before.misses, 1u);
|
||||||
@@ -649,9 +750,9 @@ TEST_F(TranslationCacheTest, TheEscapeHatchDisablesL1Entirely) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CacheModeScope cacheOff(false);
|
const CacheModeScope cacheOff(false);
|
||||||
const TranslationCacheStats before = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
const GLuint program = LinkProgramFromSources(kVertexSource, fs);
|
const GLuint program = LinkProgramFromSources(kVertexSource, fs);
|
||||||
const TranslationCacheStats after = GetSpirvTranslationCache().Stats();
|
const TranslationCacheStats after = MG_State::GLState::GetProgramTranslationCache().Stats();
|
||||||
|
|
||||||
GLint status = GL_FALSE;
|
GLint status = GL_FALSE;
|
||||||
MG_Impl::GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status);
|
MG_Impl::GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status);
|
||||||
|
|||||||
Reference in New Issue
Block a user