gpt4 book ai didi

c++ - 如何实现高效的二维批处理?

转载 作者:行者123 更新时间:2023-11-30 02:34:52 26 4
gpt4 key购买 nike

我正在尝试实现 sprite 批处理,但我不太确定应该怎么做。

纹理批处理不是很难,我只是按纹理 ID 对所有内容进行分组,但我不确定应该如何处理顶点数据。

我可以这样做

texture.bind();
gl_quad.bind();
for(auto& quad: quads){
send(quad.matrix);
draw();
}

我只会将 1 个四边形上传到 GPU,然后将矩阵作为统一变量发送并绘制四边形,但随后我会对我想要绘制的每个 Sprite 进行 1 次绘制调用,这可能不是很聪明。

或者我可以让每个 Sprite 有 4 个顶点,然后我会在 CPU 上更新它们,然后我会收集所有 Sprite 并将所有顶点上传到一个大缓冲区并绑定(bind)它。

texture.bind();
auto big_buffer = create_vertex_buffers(quads).bind();
draw();
big_buffer.delete();

我还可以使用实例化渲染。只上传一个四边形,每个 Sprite 都有一个矩阵,然后将所有矩阵上传到一个缓冲区并调用 drawIndirect。我将不得不发送 9 个 float 而不是 8 个(使用 big_buffer 版本)并且我认为 drawIndirect 比简单的 draw 命令昂贵得多。

还有其他我错过的方法吗?你会推荐什么?

最佳答案

我可以向您展示一些使用批处理及其实现的类;但他们确实依赖于其他类(class)。本作品受每个文件标题部分中的版权保护。

CommonStructs.h

// Version: 1.0
// Copyright (c) 2012 by Marek A. Krzeminski, MASc
// http://www.MarkeKnows.com

#ifndef COMMON_STRUCTS_H
#define COMMON_STRUCTS_H

namespace vmk {

// GuiVertex ------------------------------------------------------------------
struct GuiVertex {
glm::vec2 position;
glm::vec4 color;
glm::vec2 texture;

GuiVertex( glm::vec2 positionIn, glm::vec4 colorIn, glm::vec2 textureIn = glm::vec2() ) :
position( positionIn ),
color( colorIn ),
texture( textureIn )
{}
}; // GuiVertex

// BatchConfig ----------------------------------------------------------------
struct BatchConfig {
unsigned uRenderType;
int iPriority;
unsigned uTextureId;
float fAlpha;

BatchConfig( unsigned uRenderTypeIn, int iPriorityIn, unsigned uTextureIdIn, float fAlphaIn ) :
uRenderType( uRenderTypeIn ),
iPriority( iPriorityIn ),
uTextureId( uTextureIdIn ),
fAlpha( fAlphaIn )
{}

bool operator==( const BatchConfig& other ) const {
if ( uRenderType != other.uRenderType ||
iPriority != other.iPriority ||
uTextureId != other.uTextureId ||
glm::abs( fAlpha - other.fAlpha ) > 0.004f )
{
return false;
}
return true;
}

bool operator!=( const BatchConfig& other ) const {
return !( *this == other );
}
}; // BatchConfig

} // namespace vmk

#endif // COMMON_STRUCTS_H

Batch.h

// Version: 1.0
// Copyright (c) 2012 by Marek A. Krzeminski, MASc
// http://www.MarkeKnows.com

#ifndef BATCH_H
#define BATCH_H

#include "CommonStructs.h"

namespace vmk {

class ShaderManager;
class Settings;

class Batch sealed {
private:
static Settings* m_pSettings;
static ShaderManager* m_pShaderManager;

unsigned m_uMaxNumVertices;
unsigned m_uNumUsedVertices;
unsigned m_vao;
unsigned m_vbo;
BatchConfig m_config;
GuiVertex m_lastVertex;

// For Debugging Only
unsigned m_uId; // Batch Id
std::vector<std::string> m_vIds; // Id's Of What Is Contained In This Batch

public:
Batch( unsigned uId, unsigned uMaxNumVertices );
~Batch();

bool isBatchConfig( const BatchConfig& config ) const;
bool isEmpty() const;
bool isEnoughRoom( unsigned uNumVertices ) const;
Batch* getFullest( Batch* pBatch );
int getPriority() const;

void add( const std::vector<GuiVertex>& vVertices, const BatchConfig& config );
void add( const std::vector<GuiVertex>& vVertices );
void addId( const std::string& strId );
void render();

private:
Batch( const Batch& c ); // Not Implemented
Batch& operator=( const Batch& c ); // Not Implemented

void cleanUp();

}; // Batch

} // namespace vmk

#endif // BATCH_H

Batch.cpp

// Version: 1.0
// Copyright (c) 2012 by Marek A. Krzeminski, MASc
// http://www.MarkeKnows.com

#include "stdafx.h"
#include "Batch.h"

#include "Logger.h"
#include "Property.h"
#include "Settings.h"
#include "ShaderManager.h"

namespace vmk {

Settings* Batch::m_pSettings = nullptr;
ShaderManager* Batch::m_pShaderManager = nullptr;

// ----------------------------------------------------------------------------
// Batch()
Batch::Batch( unsigned uId, unsigned uMaxNumVertices ) :
m_uMaxNumVertices( uMaxNumVertices ),
m_uNumUsedVertices( 0 ),
m_vao( 0 ),
m_vbo( 0 ),
m_config(GL_TRIANGLE_STRIP, 0, 0, 1.0f ),
m_lastVertex( glm::vec2(), glm::vec4() ),
m_uId( uId ) {

if ( nullptr == m_pSettings ) {
m_pSettings = Settings::get();
}
if ( nullptr == m_pShaderManager ) {
m_pShaderManager = ShaderManager::get();
}

// Optimal Size For A Batch Is Between 1-4MB In Size. Number Of Elements That Can Be Stored In A
// Batch Is Determined By Calculating #Bytes Used By Each Vertex
if ( uMaxNumVertices < 1000 ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " uMaxNumVertices{" << uMaxNumVertices << "} is too small. Choose a number >= 1000 ";
throw ExceptionHandler( strStream );
}

// Clear Error Codes
glGetError();

if ( m_pSettings->getOpenglVersion().x >= 3 ) {
glGenVertexArrays( 1, &m_vao );
glBindVertexArray( m_vao );
}

// Create Batch Buffer
glGenBuffers( 1, &m_vbo );
glBindBuffer( GL_ARRAY_BUFFER, m_vbo );
glBufferData( GL_ARRAY_BUFFER, uMaxNumVertices * sizeof( GuiVertex ), nullptr, GL_STREAM_DRAW );

if ( m_pSettings->getOpenglVersion().x >= 3 ) {
unsigned uOffset = 0;
m_pShaderManager->enableAttribute( A_POSITION, sizeof( GuiVertex ), uOffset );
uOffset += sizeof( glm::vec2 );
m_pShaderManager->enableAttribute( A_COLOR, sizeof( GuiVertex ), uOffset );
uOffset += sizeof( glm::vec4 );
m_pShaderManager->enableAttribute( A_TEXTURE_COORD0, sizeof( GuiVertex ), uOffset );

glBindVertexArray( 0 );

m_pShaderManager->disableAttribute( A_POSITION );
m_pShaderManager->disableAttribute( A_COLOR );
m_pShaderManager->disableAttribute( A_TEXTURE_COORD0 );
}

glBindBuffer( GL_ARRAY_BUFFER, 0 );

if ( GL_NO_ERROR != glGetError() ) {
cleanUp();
throw ExceptionHandler( __FUNCTION__ + std::string( " failed to create batch" ) );
}
} // Batch

// ----------------------------------------------------------------------------
// ~Batch()
Batch::~Batch() {
cleanUp();
} // ~Batch

// ----------------------------------------------------------------------------
// cleanUp()
void Batch::cleanUp() {
if ( m_vbo != 0 ) {
glBindBuffer( GL_ARRAY_BUFFER, 0 );
glDeleteBuffers( 1, &m_vbo );
m_vbo = 0;
}
if ( m_vao != 0 ) {
glBindVertexArray( 0 );
glDeleteVertexArrays( 1, &m_vao );
m_vao = 0;
}
} // cleanUp

// ----------------------------------------------------------------------------
// isBatchConfig()
bool Batch::isBatchConfig( const BatchConfig& config ) const {
return ( config == m_config );
} // isBatchConfigh

// ----------------------------------------------------------------------------
// isEmpty()
bool Batch::isEmpty() const {
return ( 0 == m_uNumUsedVertices );
} // isEmpty

// ----------------------------------------------------------------------------
// isEnoughRoom()
// Returns True If The Number Of Vertices Passed In Can Be Stored In This Batch
// Without Reaching The Limit Of How Many Vertices Can Fit In The Batch
bool Batch::isEnoughRoom( unsigned uNumVertices ) const {
// 2 Extra Vertices Are Needed For Degenerate Triangles Between Each Strip
unsigned uNumExtraVertices = ( GL_TRIANGLE_STRIP == m_config.uRenderType && m_uNumUsedVertices > 0 ? 2 : 0 );

return ( m_uNumUsedVertices + uNumExtraVertices + uNumVertices <= m_uMaxNumVertices );
} // isEnoughRoom

// ----------------------------------------------------------------------------
// getFullest()
// Returns The Batch That Contains The Most Number Of Stored Vertices Between
// This Batch And The One Passed In
Batch* Batch::getFullest( Batch* pBatch ) {
return ( m_uNumUsedVertices > pBatch->m_uNumUsedVertices ? this : pBatch );
} // getFullest

// ----------------------------------------------------------------------------
// getPriority()
int Batch::getPriority() const {
return m_config.iPriority;
} // getPriority

// ----------------------------------------------------------------------------
// add()
// Adds Vertices To Batch And Also Sets The Batch Config Options
void Batch::add( const std::vector<GuiVertex>& vVertices, const BatchConfig& config ) {
m_config = config;
add( vVertices );
} // add

// ----------------------------------------------------------------------------
// add()
void Batch::add( const std::vector<GuiVertex>& vVertices ) {
// 2 Extra Vertices Are Needed For Degenerate Triangles Between Each Strip
unsigned uNumExtraVertices = ( GL_TRIANGLE_STRIP == m_config.uRenderType && m_uNumUsedVertices > 0 ? 2 : 0 );
if ( uNumExtraVertices + vVertices.size() > m_uMaxNumVertices - m_uNumUsedVertices ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " not enough room for {" << vVertices.size() << "} vertices in this batch. Maximum number of vertices allowed in a batch is {" << m_uMaxNumVertices << "} and {" << m_uNumUsedVertices << "} are already used";
if ( uNumExtraVertices > 0 ) {
strStream << " plus you need room for {" << uNumExtraVertices << "} extra vertices too";
}
throw ExceptionHandler( strStream );
}
if ( vVertices.size() > m_uMaxNumVertices ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " can not add {" << vVertices.size() << "} vertices to batch. Maximum number of vertices allowed in a batch is {" << m_uMaxNumVertices << "}";
throw ExceptionHandler( strStream );
}
if ( vVertices.empty() ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " can not add {" << vVertices.size() << "} vertices to batch.";
throw ExceptionHandler( strStream );
}

// Add Vertices To Buffer
if ( m_pSettings->getOpenglVersion().x >= 3 ) {
glBindVertexArray( m_vao );
}
glBindBuffer( GL_ARRAY_BUFFER, m_vbo );

if ( uNumExtraVertices > 0 ) {
// Need To Add 2 Vertex Copies To Create Degenerate Triangles Between This Strip
// And The Last Strip That Was Stored In The Batch
glBufferSubData( GL_ARRAY_BUFFER, m_uNumUsedVertices * sizeof( GuiVertex ), sizeof( GuiVertex ), &m_lastVertex );
glBufferSubData( GL_ARRAY_BUFFER, ( m_uNumUsedVertices + 1 ) * sizeof( GuiVertex ), sizeof( GuiVertex ), &vVertices[0] );
}

// TODO: Use glMapBuffer If Moving Large Chunks Of Data > 1MB
glBufferSubData( GL_ARRAY_BUFFER, ( m_uNumUsedVertices + uNumExtraVertices ) * sizeof( GuiVertex ), vVertices.size() * sizeof( GuiVertex ), &vVertices[0] );

if ( m_pSettings->getOpenglVersion().x >= 3 ) {
glBindVertexArray( 0 );
}
glBindBuffer( GL_ARRAY_BUFFER, 0 );

m_uNumUsedVertices += vVertices.size() + uNumExtraVertices;

m_lastVertex = vVertices[vVertices.size() - 1];
} // add

// ----------------------------------------------------------------------------
// addId()
void Batch::addId( const std::string& strId ) {
m_vIds.push_back( strId );
} // addId

// ----------------------------------------------------------------------------
// render()
void Batch::render() {
if ( m_uNumUsedVertices == 0 ) {
// Nothing In This Buffer To Render
return;
}

bool usingTexture = INVALID_UNSIGNED != m_config.uTextureId;
m_pShaderManager->setUniform( U_USING_TEXTURE, usingTexture );
if ( usingTexture ) {
m_pShaderManager->setTexture( 0, U_TEXTURE0_SAMPLER_2D, m_config.uTextureId );
}

m_pShaderManager->setUniform( U_ALPHA, m_config.fAlpha );

// Draw Contents To Buffer
if ( m_pSettings->getOpenglVersion().x >= 3 ) {
glBindVertexArray( m_vao );
glDrawArrays( m_config.uRenderType, 0, m_uNumUsedVertices );
glBindVertexArray( 0 );

} else { // OpenGL v2.x
glBindBuffer( GL_ARRAY_BUFFER, m_vbo );

unsigned uOffset = 0;
m_pShaderManager->enableAttribute( A_POSITION, sizeof( GuiVertex ), uOffset );
uOffset += sizeof( glm::vec2 );
m_pShaderManager->enableAttribute( A_COLOR, sizeof( GuiVertex ), uOffset );
uOffset += sizeof( glm::vec4 );
m_pShaderManager->enableAttribute( A_TEXTURE_COORD0, sizeof( GuiVertex ), uOffset );

glDrawArrays( m_config.uRenderType, 0, m_uNumUsedVertices );

m_pShaderManager->disableAttribute( A_POSITION );
m_pShaderManager->disableAttribute( A_COLOR );
m_pShaderManager->disableAttribute( A_TEXTURE_COORD0 );

glBindBuffer( GL_ARRAY_BUFFER, 0 );
}

if ( m_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
std::ostringstream strStream;

strStream << std::setw( 2 ) << m_uId << " | "
<< std::left << std::setw( 10 );

if ( GL_LINES == m_config.uRenderType ) {
strStream << "Lines";
} else if ( GL_TRIANGLES == m_config.uRenderType ) {
strStream << "Triangles";
} else if ( GL_TRIANGLE_STRIP == m_config.uRenderType ) {
strStream << "Tri Strips";
} else if ( GL_TRIANGLE_FAN == m_config.uRenderType ) {
strStream << "Tri Fan";
} else {
strStream << "Unknown";
}

strStream << " | " << std::right
<< std::setw( 6 ) << m_config.iPriority << " | "
<< std::setw( 7 ) << m_uNumUsedVertices << " | "
<< std::setw( 5 );

if ( INVALID_UNSIGNED != m_config.uTextureId ) {
strStream << m_config.uTextureId;
} else {
strStream << "None";
}
strStream << " |";

for each( const std::string& strId in m_vIds ) {
strStream << " " << strId;
}
m_vIds.clear();

Logger::log( strStream );
}

// Reset Buffer
m_uNumUsedVertices = 0;
m_config.iPriority = 0;
} // render

} // namespace vmk

BatchManager.h

// Version: 1.0
// Copyright (c) 2012 by Marek A. Krzeminski, MASc
// http://www.MarekKnows.com
#ifndef BATCH_MANAGER_H
#define BATCH_MANAGER_H

#include "Singleton.h"
#include "CommonStructs.h"

namespace vmk {

class Batch;

class BatchManager sealed : public Singleton {
private:
std::vector<std::shared_ptr<Batch>> m_vBatches;

unsigned m_uNumBatches;
unsigned m_maxNumVerticesPerBatch;

public:
BatchManager( unsigned uNumBatches, unsigned numVerticesPerBatch );
virtual ~BatchManager();

static BatchManager* const get();

void render( const std::vector<GuiVertex>& vVertices, const BatchConfig& config, const std::string& strId );
void emptyAll();

protected:
private:
BatchManager( const BatchManager& c ); // Not Implemented
BatchManager& operator=( const BatchManager& c); // Not Implemented

void emptyBatch( bool emptyAll, Batch* pBatchToEmpty );
//void renderBatch( const std::vector<GuiVertex>& vVertices, const BatchConfig& config );

}; // BatchManager

} // namespace vmk

#endif // BATCH_MANAGER_H

BatchManager.cpp

// Version: 1.0
// Copyright (c) 2012 by Marek A. Krzeminski, MASc
// http://www.MarekKnows.com

#include "stdafx.h"
#include "BatchManager.h"

#include "Batch.h"
#include "Logger.h"
#include "Settings.h"

namespace vmk {

static BatchManager* s_pBatchManager = nullptr;
static Settings* s_pSettings = nullptr;

// ----------------------------------------------------------------------------
// BatchManager()
BatchManager::BatchManager( unsigned uNumBatches, unsigned numVerticesPerBatch ) :
Singleton( TYPE_BATCH_MANAGER ),
m_uNumBatches( uNumBatches ),
m_maxNumVerticesPerBatch( numVerticesPerBatch ) {

// Test Input Parameters
if ( uNumBatches < 10 ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " uNumBatches{" << uNumBatches << "} is too small. Choose a number >= 10 ";
throw ExceptionHandler( strStream );
}

// A Good Size For Each Batch Is Between 1-4MB In Size. Number Of Elements That Can Be Stored In A
// Batch Is Determined By Calculating #Bytes Used By Each Vertex
if ( numVerticesPerBatch < 1000 ) {
std::ostringstream strStream;
strStream << __FUNCTION__ << " numVerticesPerBatch{" << numVerticesPerBatch << "} is too small. Choose A Number >= 1000 ";
throw ExceptionHandler( strStream );
}

// Create Desired Number Of Batches
m_vBatches.reserve( uNumBatches );
for ( unsigned u = 0; u < uNumBatches; ++u ) {
m_vBatches.push_back( std::shared_ptr<Batch>( new Batch( u, numVerticesPerBatch ) ) );
}

s_pSettings = Settings::get();
s_pBatchManager = this;
} // BatchManager

// ----------------------------------------------------------------------------
// ~BatchManager()
BatchManager::~BatchManager() {
s_pBatchManager = nullptr;

m_vBatches.clear();
} // ~BatchManager

// ----------------------------------------------------------------------------
// get()
BatchManager* const BatchManager::get() {
if ( nullptr == s_pBatchManager ) {
throw ExceptionHandler( __FUNCTION__ + std::string( " failed, BatchManager has not been constructed yet" ) );
}
return s_pBatchManager;
} // get

// ----------------------------------------------------------------------------
// render()
void BatchManager::render( const std::vector<GuiVertex>& vVertices, const BatchConfig& config, const std::string& strId ) {

Batch* pEmptyBatch = nullptr;
Batch* pFullestBatch = m_vBatches[0].get();

// Determine Which Batch To Put The Vertices Into
for ( unsigned u = 0; u < m_uNumBatches; ++u ) {
Batch* pBatch = m_vBatches[u].get();

if ( pBatch->isBatchConfig( config ) ) {
if ( !pBatch->isEnoughRoom( vVertices.size() ) ) {
// First Need To Empty This Batch Before Adding Anything To It
emptyBatch( false, pBatch );
if ( s_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
Logger::log( "Forced batch to empty to make room for vertices" );
}
}
if ( s_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
pBatch->addId( strId );
}
pBatch->add( vVertices );

return;
}

// Store Pointer To First Empty Batch
if ( nullptr == pEmptyBatch && pBatch->isEmpty() ) {
pEmptyBatch = pBatch;
}

// Store Pointer To Fullest Batch
pFullestBatch = pBatch->getFullest( pFullestBatch );
}

// If We Get Here Then We Didn't Find An Appropriate Batch To Put The Vertices Into
// If We Have An Empty Batch, Put Vertices There
if ( nullptr != pEmptyBatch ) {
if ( s_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
pEmptyBatch->addId( strId );
}
pEmptyBatch->add( vVertices, config );
return;
}

// No Empty Batches Were Found Therefore We Must Empty One First And Then We Can Use It
emptyBatch( false, pFullestBatch );
if ( s_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
Logger::log( "Forced fullest batch to empty to make room for vertices" );

pFullestBatch->addId( strId );
}
pFullestBatch->add( vVertices, config );
} // render

// ----------------------------------------------------------------------------
// emptyAll()
void BatchManager::emptyAll() {
emptyBatch( true, m_vBatches[0].get() );

if ( s_pSettings->isDebugLoggingEnabled( Settings::DEBUG_RENDER ) ) {
Logger::log( "Forced all batches to empty" );
}
} // emptyAll

// ----------------------------------------------------------------------------
// CompareBatch
struct CompareBatch : public std::binary_function<Batch*, Batch*, bool> {
bool operator()( const Batch* pBatchA, const Batch* pBatchB ) const {
return ( pBatchA->getPriority() > pBatchB->getPriority() );
} // operator()
}; // CompareFunctor

// ----------------------------------------------------------------------------
// emptyBatch()
// Empties The Batches According To Priority. If emptyAll() Is False Then
// Only Empty The Batches That Are Lower Priority Than The One Specified
// AND Also Empty The One That Is Passed In
void BatchManager::emptyBatch( bool emptyAll, Batch* pBatchToEmpty ) {
// Sort Bathes By Priority
std::priority_queue<Batch*, std::vector<Batch*>, CompareBatch> queue;

for ( unsigned u = 0; u < m_uNumBatches; ++u ) {
// Add All Non-Empty Batches To Queue Which Will Be Sorted By Order
// From Lowest To Highest Priority
if ( !m_vBatches[u]->isEmpty() ) {
if ( emptyAll ) {
queue.push( m_vBatches[u].get() );
} else if ( m_vBatches[u]->getPriority() < pBatchToEmpty->getPriority() ) {
// Only Add Batches That Are Lower In Priority
queue.push( m_vBatches[u].get() );
}
}
}

// Render All Desired Batches
while ( !queue.empty() ) {
Batch* pBatch = queue.top();
pBatch->render();
queue.pop();
}

if ( !emptyAll ) {
// When Not Emptying All The Batches, We Still Want To Empty
// The Batch That Is Passed In, In Addition To All Batches
// That Have Lower Priority Than It
pBatchToEmpty->render();
}
} // emptyBatch

} // namespace vmk

现在这些类不会直接编译,因为它们依赖并依赖于其他类对象:Settings、Properties、ShaderManager、Logger,而这些对象也依赖于其他对象。这来自使用 OpenGL 着色器的大规模工作 OpenGL 图形渲染和游戏引擎。这是有效的源代码,最佳无错误。

这可以作为如何设计批处理过程的指南。并且可能深入了解要考虑的事情,例如:正在渲染的顶点类型 { Lines、Triangles、TriangleStrip、TriangleFan 等},根据对象是否具有透明度来绘制对象的优先级,处理退化三角形创建批处理对象时的顶点。

这种设计的方式是只有匹配的批处理类型才能放入同一个桶中,并且桶会尝试填充自己,如果它太满而无法容纳顶点,它将寻找另一个桶以查看是否一个是可用的,如果没有可用的桶,它将搜索哪个桶是最满的,并将它们从优先级队列中清空,以将顶点发送到要渲染的视频卡。

这与管理 OpenGL 定义和设置着色器程序并将它们链接到程序的方式的 ShaderManager 相关联,它还与此处未找到但在 ShaderManager 中找到的 AssetStorage 类相关联。该系统处理完整的自定义 GUI、 Sprite 、字体、纹理等。

如果您想了解更多信息,我强烈建议您访问 www.MarekKnows.com并查看他关于 OpenGL 的视频教程系列;对于这个特定的应用程序,您需要关注他的着色器引擎系列!

关于c++ - 如何实现高效的二维批处理?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/34259716/

26 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com