mirror of
https://github.com/TorqueGameEngines/Torque3D.git
synced 2026-01-20 04:34:48 +00:00
587 lines
18 KiB
C++
587 lines
18 KiB
C++
//-----------------------------------------------------------------------------
|
|
// Copyright (c) 2012 GarageGames, LLC
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to
|
|
// deal in the Software without restriction, including without limitation the
|
|
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
// sell copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
// IN THE SOFTWARE.
|
|
//-----------------------------------------------------------------------------
|
|
|
|
#include "platform/platform.h"
|
|
#include "ts/tsLastDetail.h"
|
|
|
|
#include "renderInstance/renderPassManager.h"
|
|
#include "ts/tsShapeInstance.h"
|
|
#include "scene/sceneManager.h"
|
|
#include "scene/sceneRenderState.h"
|
|
#include "lighting/lightInfo.h"
|
|
#include "renderInstance/renderImposterMgr.h"
|
|
#include "gfx/gfxTransformSaver.h"
|
|
#include "gfx/bitmap/ddsFile.h"
|
|
#include "gfx/bitmap/imageUtils.h"
|
|
#include "gfx/gfxTextureManager.h"
|
|
#include "math/mRandom.h"
|
|
#include "core/stream/fileStream.h"
|
|
#include "util/imposterCapture.h"
|
|
#include "materials/materialManager.h"
|
|
#include "materials/materialFeatureTypes.h"
|
|
#include "console/consoleTypes.h"
|
|
#include "console/engineAPI.h"
|
|
|
|
|
|
GFXImplementVertexFormat( ImposterState )
|
|
{
|
|
addElement( "POSITION", GFXDeclType_Float4 );
|
|
|
|
addElement( "ImposterParams", GFXDeclType_Float2, 0 );
|
|
|
|
addElement( "ImposterUpVec", GFXDeclType_Float3, 1 );
|
|
addElement( "ImposterRightVec", GFXDeclType_Float3, 2 );
|
|
};
|
|
|
|
|
|
Vector<TSLastDetail*> TSLastDetail::smLastDetails;
|
|
|
|
bool TSLastDetail::smCanShadow = true;
|
|
|
|
|
|
AFTER_MODULE_INIT( Sim )
|
|
{
|
|
Con::addVariable( "$pref::imposter::canShadow", TypeBool, &TSLastDetail::smCanShadow,
|
|
"User preference which toggles shadows from imposters. Defaults to true.\n"
|
|
"@ingroup Rendering\n" );
|
|
}
|
|
|
|
|
|
TSLastDetail::TSLastDetail( TSShape *shape,
|
|
const String &cachePath,
|
|
U32 numEquatorSteps,
|
|
U32 numPolarSteps,
|
|
F32 polarAngle,
|
|
bool includePoles,
|
|
S32 dl, S32 dim )
|
|
{
|
|
mNumEquatorSteps = getMax( numEquatorSteps, (U32)1 );
|
|
mNumPolarSteps = numPolarSteps;
|
|
mPolarAngle = polarAngle;
|
|
mIncludePoles = includePoles;
|
|
mShape = shape;
|
|
mDl = dl;
|
|
mDim = getMax( dim, (S32)32 );
|
|
|
|
mRadius = mShape->mRadius;
|
|
mCenter = mShape->center;
|
|
|
|
mCachePath = cachePath;
|
|
mDiffusePath = mCachePath + "_imposter.dds";
|
|
mNormalPath = mCachePath + "_imposter_normals.dds";
|
|
|
|
mMaterial = NULL;
|
|
mMatInstance = NULL;
|
|
|
|
// Store this in the static list.
|
|
smLastDetails.push_back( this );
|
|
}
|
|
|
|
TSLastDetail::TSLastDetail(TSShape* shape,
|
|
const String& cachePath,
|
|
const String& diffusePath,
|
|
const String& normalPath,
|
|
U32 numEquatorSteps,
|
|
U32 numPolarSteps,
|
|
F32 polarAngle,
|
|
bool includePoles,
|
|
S32 dl, S32 dim)
|
|
{
|
|
mNumEquatorSteps = getMax(numEquatorSteps, (U32)1);
|
|
mNumPolarSteps = numPolarSteps;
|
|
mPolarAngle = polarAngle;
|
|
mIncludePoles = includePoles;
|
|
mShape = shape;
|
|
mDl = dl;
|
|
mDim = getMax(dim, (S32)32);
|
|
|
|
mRadius = mShape->mRadius;
|
|
mCenter = mShape->center;
|
|
|
|
mCachePath = cachePath;
|
|
mDiffusePath = diffusePath;
|
|
mNormalPath = normalPath;
|
|
|
|
mMaterial = NULL;
|
|
mMatInstance = NULL;
|
|
|
|
// Store this in the static list.
|
|
smLastDetails.push_back(this);
|
|
}
|
|
|
|
TSLastDetail::~TSLastDetail()
|
|
{
|
|
SAFE_DELETE( mMatInstance );
|
|
if ( mMaterial )
|
|
mMaterial->deleteObject();
|
|
|
|
// Remove ourselves from the list.
|
|
Vector<TSLastDetail*>::iterator iter = T3D::find( smLastDetails.begin(), smLastDetails.end(), this );
|
|
smLastDetails.erase( iter );
|
|
}
|
|
|
|
void TSLastDetail::render( const TSRenderState &rdata, F32 alpha )
|
|
{
|
|
// Early out if we have nothing to render.
|
|
if ( alpha < 0.01f ||
|
|
!mMatInstance ||
|
|
mMaterial->mImposterUVs.size() == 0 )
|
|
return;
|
|
|
|
const MatrixF &mat = GFX->getWorldMatrix();
|
|
|
|
// Post a render instance for this imposter... the special
|
|
// imposter render manager will do the magic!
|
|
RenderPassManager *renderPass = rdata.getSceneState()->getRenderPass();
|
|
|
|
ImposterRenderInst *ri = renderPass->allocInst<ImposterRenderInst>();
|
|
ri->mat = rdata.getSceneState()->getOverrideMaterial( mMatInstance );
|
|
|
|
ri->state.alpha = alpha;
|
|
|
|
// Store the up and right vectors of the rotation
|
|
// and we'll generate the up vector in the shader.
|
|
//
|
|
// This is faster than building a quat on the
|
|
// CPU and then rebuilding the matrix on the GPU.
|
|
//
|
|
// NOTE: These vector include scale.
|
|
//
|
|
mat.getColumn( 2, &ri->state.upVec );
|
|
mat.getColumn( 0, &ri->state.rightVec );
|
|
|
|
// We send the unscaled size and the vertex shader
|
|
// will use the orientation vectors above to scale it.
|
|
ri->state.halfSize = mRadius;
|
|
|
|
// We use the center of the object bounds for
|
|
// the center of the billboard quad.
|
|
mat.mulP( mCenter, &ri->state.center );
|
|
|
|
// We sort by the imposter type first so that RIT_Imposter and s
|
|
// RIT_ImposterBatches do not get mixed together.
|
|
//
|
|
// We then sort by material.
|
|
//
|
|
ri->defaultKey = 1;
|
|
ri->defaultKey2 = ri->mat->getStateHint();
|
|
|
|
renderPass->addInst( ri );
|
|
}
|
|
|
|
void TSLastDetail::update( bool forceUpdate )
|
|
{
|
|
// This should never be called on a dedicated server or
|
|
// anywhere else where we don't have a GFX device!
|
|
AssertFatal( GFXDevice::devicePresent(), "TSLastDetail::update() - Cannot update without a GFX device!" );
|
|
|
|
// Clear the materialfirst.
|
|
SAFE_DELETE( mMatInstance );
|
|
if ( mMaterial )
|
|
{
|
|
mMaterial->deleteObject();
|
|
mMaterial = NULL;
|
|
}
|
|
|
|
// Make sure imposter textures have been flushed (and not just queued for deletion)
|
|
TEXMGR->cleanupCache();
|
|
|
|
// Get the real path to the source shape for doing modified time
|
|
// comparisons... this might be different if the DAEs have been
|
|
// deleted from the install.
|
|
String shapeFile( mCachePath );
|
|
if ( !Torque::FS::IsFile( shapeFile ) )
|
|
{
|
|
Torque::Path path(shapeFile);
|
|
path.setExtension("cached.dts");
|
|
shapeFile = path.getFullPath();
|
|
if ( !Torque::FS::IsFile( shapeFile ) )
|
|
{
|
|
Con::errorf( "TSLastDetail::update - '%s' could not be found!", mCachePath.c_str() );
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Do we need to update the imposter?
|
|
const String diffuseMapPath = _getDiffuseMapPath();
|
|
bool isFile = Platform::isFile(diffuseMapPath.c_str());
|
|
if ( forceUpdate || !isFile || Platform::compareModifiedTimes( diffuseMapPath, shapeFile ) <= 0 )
|
|
_update();
|
|
|
|
// If the time check fails now then the update must have not worked.
|
|
if ( Platform::compareModifiedTimes( diffuseMapPath, shapeFile ) < 0 )
|
|
{
|
|
Con::errorf( "TSLastDetail::update - Failed to create imposters for '%s'!", mCachePath.c_str() );
|
|
return;
|
|
}
|
|
|
|
// Figure out what our vertex format will be.
|
|
//
|
|
// If we're on SM 3.0 we can do multiple vertex streams
|
|
// and the performance win is big as we send 3x less data
|
|
// on each imposter instance.
|
|
//
|
|
// The problem is SM 2.0 won't do this, so we need to
|
|
// support fallback to regular single stream imposters.
|
|
//
|
|
//mImposterVertDecl.copy( *getGFXVertexFormat<ImposterCorner>() );
|
|
//mImposterVertDecl.append( *getGFXVertexFormat<ImposterState>(), 1 );
|
|
//mImposterVertDecl.getDecl();
|
|
mImposterVertDecl.clear();
|
|
mImposterVertDecl.copy( *getGFXVertexFormat<ImposterState>() );
|
|
|
|
// Setup the material for this imposter.
|
|
mMaterial = MATMGR->allocateAndRegister( String::EmptyString );
|
|
mMaterial->mAutoGenerated = true;
|
|
mMaterial->setDiffuseMapFile(diffuseMapPath, 0);
|
|
mMaterial->setNormalMapFile(_getNormalMapPath(), 0);
|
|
|
|
mMaterial->mImposterLimits.set( (mNumPolarSteps * 2) + 1, mNumEquatorSteps, mPolarAngle, mIncludePoles );
|
|
mMaterial->mTranslucent = true;
|
|
mMaterial->mTranslucentBlendOp = Material::None;
|
|
mMaterial->mTranslucentZWrite = true;
|
|
mMaterial->mDoubleSided = true;
|
|
mMaterial->mAlphaTest = true;
|
|
mMaterial->mAlphaRef = 84;
|
|
|
|
// Create the material instance.
|
|
FeatureSet features = MATMGR->getDefaultFeatures();
|
|
features.addFeature( MFT_ImposterVert );
|
|
mMatInstance = mMaterial->createMatInstance();
|
|
if ( !mMatInstance->init( features, &mImposterVertDecl ) )
|
|
{
|
|
delete mMatInstance;
|
|
mMatInstance = NULL;
|
|
}
|
|
|
|
// Get the diffuse texture and from its size and
|
|
// the imposter dimensions we can generate the UVs.
|
|
GFXTexHandle diffuseTex( diffuseMapPath, &GFXStaticTextureSRGBProfile, String::EmptyString );
|
|
Point2I texSize( diffuseTex->getWidth(), diffuseTex->getHeight() );
|
|
|
|
_validateDim();
|
|
|
|
S32 downscaledDim = mDim >> GFXTextureManager::getTextureDownscalePower(&GFXStaticTextureSRGBProfile);
|
|
|
|
// Ok... pack in bitmaps till we run out.
|
|
Vector<RectF> imposterUVs;
|
|
for ( S32 y=0; y+downscaledDim <= texSize.y; )
|
|
{
|
|
for ( S32 x=0; x+downscaledDim <= texSize.x; )
|
|
{
|
|
// Store the uv for later lookup.
|
|
RectF info;
|
|
info.point.set( (F32)x / (F32)texSize.x, (F32)y / (F32)texSize.y );
|
|
info.extent.set( (F32)downscaledDim / (F32)texSize.x, (F32)downscaledDim / (F32)texSize.y );
|
|
imposterUVs.push_back( info );
|
|
|
|
x += downscaledDim;
|
|
}
|
|
|
|
y += downscaledDim;
|
|
}
|
|
|
|
AssertFatal( imposterUVs.size() != 0, "hey" );
|
|
|
|
mMaterial->mImposterUVs = imposterUVs;
|
|
}
|
|
|
|
void TSLastDetail::_validateDim()
|
|
{
|
|
// Loop till they fit.
|
|
S32 newDim = mDim;
|
|
while ( true )
|
|
{
|
|
S32 maxImposters = ( smMaxTexSize / newDim ) * ( smMaxTexSize / newDim );
|
|
S32 imposterCount = ( ((2*mNumPolarSteps) + 1 ) * mNumEquatorSteps ) + ( mIncludePoles ? 2 : 0 );
|
|
if ( imposterCount <= maxImposters )
|
|
break;
|
|
|
|
// There are too many imposters to fit a single
|
|
// texture, so we fail. These imposters are for
|
|
// rendering small distant objects. If you need
|
|
// a really high resolution imposter or many images
|
|
// around the equator and poles, maybe you need a
|
|
// custom solution.
|
|
|
|
newDim /= 2;
|
|
}
|
|
|
|
if ( newDim != mDim )
|
|
{
|
|
Con::printf( "TSLastDetail::_validateDim - '%s' detail dimensions too big! Reduced from %d to %d.",
|
|
mCachePath.c_str(),
|
|
mDim, newDim );
|
|
|
|
mDim = newDim;
|
|
}
|
|
}
|
|
|
|
void TSLastDetail::_update()
|
|
{
|
|
// We're gonna render... make sure we can.
|
|
bool sceneBegun = GFX->canCurrentlyRender();
|
|
if ( !sceneBegun )
|
|
GFX->beginScene();
|
|
|
|
_validateDim();
|
|
|
|
Vector<GBitmap*> bitmaps;
|
|
Vector<GBitmap*> normalmaps;
|
|
|
|
// We need to create our own instance to render with.
|
|
TSShapeInstance *shape = new TSShapeInstance( mShape, true );
|
|
|
|
// Animate the shape once.
|
|
shape->animate( mDl );
|
|
|
|
// So we don't have to change it everywhere.
|
|
const GFXFormat format = GFXFormatR8G8B8A8;
|
|
|
|
S32 imposterCount = ( ((2*mNumPolarSteps) + 1 ) * mNumEquatorSteps ) + ( mIncludePoles ? 2 : 0 );
|
|
|
|
// Figure out the optimal texture size.
|
|
Point2I texSize( smMaxTexSize, smMaxTexSize );
|
|
while ( true )
|
|
{
|
|
Point2I halfSize( texSize.x / 2, texSize.y / 2 );
|
|
U32 count = ( halfSize.x / mDim ) * ( halfSize.y / mDim );
|
|
if ( count < imposterCount )
|
|
{
|
|
// Try half of the height.
|
|
count = ( texSize.x / mDim ) * ( halfSize.y / mDim );
|
|
if ( count >= imposterCount )
|
|
texSize.y = halfSize.y;
|
|
break;
|
|
}
|
|
|
|
texSize = halfSize;
|
|
}
|
|
|
|
GBitmap *imposter = NULL;
|
|
GBitmap *normalmap = NULL;
|
|
GBitmap destBmp( texSize.x, texSize.y, true, format );
|
|
GBitmap destNormal( texSize.x, texSize.y, true, format );
|
|
|
|
U32 mipLevels = destBmp.getNumMipLevels();
|
|
|
|
ImposterCapture *imposterCap = new ImposterCapture();
|
|
|
|
F32 equatorStepSize = M_2PI_F / (F32)mNumEquatorSteps;
|
|
|
|
static const MatrixF topXfm( EulerF( -M_PI_F / 2.0f, 0, 0 ) );
|
|
static const MatrixF bottomXfm( EulerF( M_PI_F / 2.0f, 0, 0 ) );
|
|
|
|
MatrixF angMat;
|
|
|
|
F32 polarStepSize = 0.0f;
|
|
if ( mNumPolarSteps > 0 )
|
|
polarStepSize = -( 0.5f * M_PI_F - mDegToRad( mPolarAngle ) ) / (F32)mNumPolarSteps;
|
|
|
|
PROFILE_START(TSLastDetail_snapshots);
|
|
|
|
S32 currDim = mDim;
|
|
for ( S32 mip = 0; mip < mipLevels; mip++ )
|
|
{
|
|
if ( currDim < 1 )
|
|
currDim = 1;
|
|
|
|
dMemset( destBmp.getWritableBits(mip), 0, destBmp.getWidth(mip) * destBmp.getHeight(mip) * GFXFormat_getByteSize( format ) );
|
|
dMemset( destNormal.getWritableBits(mip), 0, destNormal.getWidth(mip) * destNormal.getHeight(mip) * GFXFormat_getByteSize( format ) );
|
|
|
|
bitmaps.clear();
|
|
normalmaps.clear();
|
|
|
|
F32 rotX = 0.0f;
|
|
if ( mNumPolarSteps > 0 )
|
|
rotX = -( mDegToRad( mPolarAngle ) - 0.5f * M_PI_F );
|
|
|
|
// We capture the images in a particular order which must
|
|
// match the order expected by the imposter renderer.
|
|
|
|
imposterCap->begin( shape, mDl, currDim, mRadius, mCenter );
|
|
|
|
for ( U32 j=0; j < (2 * mNumPolarSteps + 1); j++ )
|
|
{
|
|
F32 rotZ = -M_PI_F / 2.0f;
|
|
|
|
for ( U32 k=0; k < mNumEquatorSteps; k++ )
|
|
{
|
|
angMat.mul( MatrixF( EulerF( rotX, 0, 0 ) ),
|
|
MatrixF( EulerF( 0, 0, rotZ ) ) );
|
|
|
|
imposterCap->capture( angMat, &imposter, &normalmap );
|
|
|
|
bitmaps.push_back( imposter );
|
|
normalmaps.push_back( normalmap );
|
|
|
|
rotZ += equatorStepSize;
|
|
}
|
|
|
|
rotX += polarStepSize;
|
|
|
|
if ( mIncludePoles )
|
|
{
|
|
imposterCap->capture( topXfm, &imposter, &normalmap );
|
|
|
|
bitmaps.push_back(imposter);
|
|
normalmaps.push_back( normalmap );
|
|
|
|
imposterCap->capture( bottomXfm, &imposter, &normalmap );
|
|
|
|
bitmaps.push_back( imposter );
|
|
normalmaps.push_back( normalmap );
|
|
}
|
|
}
|
|
|
|
imposterCap->end();
|
|
|
|
Point2I atlasSize( destBmp.getWidth(mip), destBmp.getHeight(mip) );
|
|
|
|
// Ok... pack in bitmaps till we run out.
|
|
for ( S32 y=0; y+currDim <= atlasSize.y; )
|
|
{
|
|
for ( S32 x=0; x+currDim <= atlasSize.x; )
|
|
{
|
|
// Copy the next bitmap to the dest texture.
|
|
GBitmap* cell = bitmaps.first();
|
|
bitmaps.pop_front();
|
|
destBmp.copyRect(cell, RectI( 0, 0, currDim, currDim ), Point2I( x, y ), 0, mip );
|
|
delete cell;
|
|
|
|
// Copy the next normal to the dest texture.
|
|
GBitmap* cellNormalmap = normalmaps.first();
|
|
normalmaps.pop_front();
|
|
destNormal.copyRect(cellNormalmap, RectI( 0, 0, currDim, currDim ), Point2I( x, y ), 0, mip );
|
|
delete cellNormalmap;
|
|
|
|
// Did we finish?
|
|
if ( bitmaps.empty() )
|
|
break;
|
|
|
|
x += currDim;
|
|
}
|
|
|
|
// Did we finish?
|
|
if ( bitmaps.empty() )
|
|
break;
|
|
|
|
y += currDim;
|
|
}
|
|
|
|
// Next mip...
|
|
currDim /= 2;
|
|
}
|
|
|
|
PROFILE_END(); // TSLastDetail_snapshots
|
|
|
|
delete imposterCap;
|
|
delete shape;
|
|
|
|
|
|
// Should we dump the images?
|
|
if ( Con::getBoolVariable( "$TSLastDetail::dumpImposters", false ) )
|
|
{
|
|
String imposterPath = _getDiffuseMapPath();
|
|
String normalsPath = _getNormalMapPath();
|
|
|
|
FileStream stream;
|
|
if ( stream.open( imposterPath, Torque::FS::File::Write ) )
|
|
destBmp.writeBitmap( "png", stream );
|
|
stream.close();
|
|
|
|
if ( stream.open( normalsPath, Torque::FS::File::Write ) )
|
|
destNormal.writeBitmap( "png", stream );
|
|
stream.close();
|
|
}
|
|
|
|
// DEBUG: Some code to force usage of a test image.
|
|
//GBitmap* tempMap = GBitmap::load( "./forest/data/test1234.png" );
|
|
//tempMap->extrudeMipLevels();
|
|
//mTexture.set( tempMap, &GFXStaticTextureSRGBProfile, false );
|
|
//delete tempMap;
|
|
|
|
DDSFile *ddsDest = DDSFile::createDDSFileFromGBitmap( &destBmp );
|
|
ImageUtil::ddsCompress( ddsDest, GFXFormatBC2 );
|
|
|
|
DDSFile *ddsNormals = DDSFile::createDDSFileFromGBitmap( &destNormal );
|
|
ImageUtil::ddsCompress( ddsNormals, GFXFormatBC3 );
|
|
|
|
// Finally save the imposters to disk.
|
|
FileStream fs;
|
|
if ( fs.open( _getDiffuseMapPath(), Torque::FS::File::Write ) )
|
|
{
|
|
ddsDest->write( fs );
|
|
fs.close();
|
|
}
|
|
if ( fs.open( _getNormalMapPath(), Torque::FS::File::Write ) )
|
|
{
|
|
ddsNormals->write( fs );
|
|
fs.close();
|
|
}
|
|
|
|
delete ddsDest;
|
|
delete ddsNormals;
|
|
|
|
// If we did a begin then end it now.
|
|
if ( !sceneBegun )
|
|
GFX->endScene();
|
|
}
|
|
|
|
void TSLastDetail::deleteImposterCacheTextures()
|
|
{
|
|
const String diffuseMap = _getDiffuseMapPath();
|
|
if ( diffuseMap.length() )
|
|
dFileDelete( diffuseMap );
|
|
|
|
const String normalMap = _getNormalMapPath();
|
|
if ( normalMap.length() )
|
|
dFileDelete( normalMap );
|
|
}
|
|
|
|
void TSLastDetail::updateImposterImages( bool forceUpdate )
|
|
{
|
|
// Can't do it without GFX!
|
|
if ( !GFXDevice::devicePresent() )
|
|
return;
|
|
|
|
//D3DPERF_SetMarker( D3DCOLOR_RGBA( 0, 255, 0, 255 ), L"TSLastDetail::makeImposter" );
|
|
|
|
bool sceneBegun = GFX->canCurrentlyRender();
|
|
if ( !sceneBegun )
|
|
GFX->beginScene();
|
|
|
|
Vector<TSLastDetail*>::iterator iter = smLastDetails.begin();
|
|
for ( ; iter != smLastDetails.end(); iter++ )
|
|
(*iter)->update( forceUpdate );
|
|
|
|
if ( !sceneBegun )
|
|
GFX->endScene();
|
|
}
|
|
|
|
DefineEngineFunction( tsUpdateImposterImages, void, (bool forceUpdate), (false), "tsUpdateImposterImages( bool forceupdate )")
|
|
{
|
|
TSLastDetail::updateImposterImages(forceUpdate);
|
|
}
|
|
|
|
|