diff --git a/Changes.md b/Changes.md index 305f763c01b..97476aaeba5 100644 --- a/Changes.md +++ b/Changes.md @@ -1,3 +1,13 @@ +1.3.x.x (relative to 1.3.13.1) +======= + +Features +-------- + +- Render, InteractiveRender : Added new nodes capable of rendering to any supported renderer, and using the`render:defaultRenderer` option to determine which to use by default. +- StandardOptions : Added `render:defaultRenderer` option, allowing the scene globals to specify which renderer is used by the Render and InteractiveRender nodes. +- RenderPassEditor : Added a column for the `render:defaultRenderer` option, allowing each pass to be rendered in a different renderer. + 1.3.13.1 (relative to 1.3.13.0) ======== diff --git a/include/GafferScene/Private/IECoreScenePreview/Renderer.h b/include/GafferScene/Private/IECoreScenePreview/Renderer.h index f299824a5c7..ef36ab5e72c 100644 --- a/include/GafferScene/Private/IECoreScenePreview/Renderer.h +++ b/include/GafferScene/Private/IECoreScenePreview/Renderer.h @@ -305,6 +305,10 @@ class GAFFERSCENE_API Renderer : public IECore::RefCounted /// Performs an arbitrary renderer-specific action. virtual IECore::DataPtr command( const IECore::InternedString name, const IECore::CompoundDataMap ¶meters = IECore::CompoundDataMap() ); + using Creator = std::function; + static void registerType( const IECore::InternedString &typeName, Creator creator ); + static void deregisterType( const IECore::InternedString &typeName ); + protected : Renderer(); @@ -331,11 +335,6 @@ class GAFFERSCENE_API Renderer : public IECore::RefCounted }; - private : - - static void registerType( const IECore::InternedString &typeName, Ptr (*creator)( RenderType, const std::string &, const IECore::MessageHandlerPtr & ) ); - - }; IE_CORE_DECLAREPTR( Renderer ) diff --git a/python/GafferArnoldTest/ArnoldRenderTest.py b/python/GafferArnoldTest/ArnoldRenderTest.py index d4b05aceaf5..403fb901be3 100644 --- a/python/GafferArnoldTest/ArnoldRenderTest.py +++ b/python/GafferArnoldTest/ArnoldRenderTest.py @@ -59,7 +59,10 @@ import GafferArnold import GafferArnoldTest -class ArnoldRenderTest( GafferSceneTest.SceneTestCase ) : +class ArnoldRenderTest( GafferSceneTest.RenderTest ) : + + renderer = "Arnold" + sceneDescriptionSuffix = ".ass" def setUp( self ) : @@ -73,110 +76,6 @@ def tearDown( self ) : GafferScene.SceneAlgo.deregisterRenderAdaptor( "Test" ) - def testExecute( self ) : - - s = Gaffer.ScriptNode() - - s["plane"] = GafferScene.Plane() - s["render"] = GafferArnold.ArnoldRender() - s["render"]["mode"].setValue( s["render"].Mode.SceneDescriptionMode ) - s["render"]["in"].setInput( s["plane"]["out"] ) - - s["expression"] = Gaffer.Expression() - s["expression"].setExpression( f"""parent['render']['fileName'] = '{( self.temporaryDirectory() / "test.%d.ass" ).as_posix()}' % int( context['frame'] )""" ) - - s["fileName"].setValue( self.__scriptFileName ) - s.save() - - p = subprocess.Popen( - f"gaffer execute {self.__scriptFileName} -frames 1-3", - shell=True, - stderr = subprocess.PIPE, - ) - p.wait() - self.assertFalse( p.returncode ) - - for i in range( 1, 4 ) : - self.assertTrue( ( self.temporaryDirectory() / f"test.{i}.ass" ).exists() ) - - def testWaitForImage( self ) : - - s = Gaffer.ScriptNode() - - s["plane"] = GafferScene.Plane() - - s["outputs"] = GafferScene.Outputs() - s["outputs"].addOutput( - "beauty", - IECoreScene.Output( - str( self.temporaryDirectory() / "test.tif" ), - "tiff", - "rgba", - {} - ) - ) - s["outputs"]["in"].setInput( s["plane"]["out"] ) - - s["render"] = GafferArnold.ArnoldRender() - s["render"]["in"].setInput( s["outputs"]["out"] ) - s["render"]["task"].execute() - - self.assertTrue( ( self.temporaryDirectory() / "test.tif" ).exists() ) - - def testExecuteWithStringSubstitutions( self ) : - - s = Gaffer.ScriptNode() - - s["plane"] = GafferScene.Plane() - s["render"] = GafferArnold.ArnoldRender() - s["render"]["mode"].setValue( s["render"].Mode.SceneDescriptionMode ) - s["render"]["in"].setInput( s["plane"]["out"] ) - s["render"]["fileName"].setValue( self.temporaryDirectory() / "test.####.ass" ) - - s["fileName"].setValue( self.__scriptFileName ) - s.save() - - p = subprocess.Popen( - f"gaffer execute {self.__scriptFileName} -frames 1-3", - shell=True, - stderr = subprocess.PIPE, - ) - p.wait() - self.assertFalse( p.returncode ) - - for i in range( 1, 4 ) : - self.assertTrue( ( self.temporaryDirectory() / f"test.{i:04d}.ass" ).exists() ) - - def testImageOutput( self ) : - - s = Gaffer.ScriptNode() - - s["plane"] = GafferScene.Plane() - - s["outputs"] = GafferScene.Outputs() - s["outputs"].addOutput( - "beauty", - IECoreScene.Output( - str( self.temporaryDirectory() / "test.####.tif" ), - "tiff", - "rgba", - {} - ) - ) - s["outputs"]["in"].setInput( s["plane"]["out"] ) - - s["render"] = GafferArnold.ArnoldRender() - s["render"]["in"].setInput( s["outputs"]["out"] ) - - c = Gaffer.Context() - for i in range( 1, 4 ) : - c.setFrame( i ) - with c : - s["render"]["task"].execute() - - for i in range( 1, 4 ) : - self.assertTrue( ( self.temporaryDirectory() / f"test.{i:04d}.tif" ).exists() ) - def testTypeNamePrefixes( self ) : self.assertTypeNamesArePrefixed( GafferArnold ) @@ -192,53 +91,6 @@ def testNodesConstructWithDefaultValues( self ) : self.assertNodesConstructWithDefaultValues( GafferArnold ) self.assertNodesConstructWithDefaultValues( GafferArnoldTest ) - def testDirectoryCreation( self ) : - - s = Gaffer.ScriptNode() - s["variables"].addChild( Gaffer.NameValuePlug( "renderDirectory", ( self.temporaryDirectory() / "renderTests" ).as_posix() ) ) - s["variables"].addChild( Gaffer.NameValuePlug( "assDirectory", ( self.temporaryDirectory() / "assTests" ).as_posix() ) ) - - s["plane"] = GafferScene.Plane() - - s["outputs"] = GafferScene.Outputs() - s["outputs"]["in"].setInput( s["plane"]["out"] ) - s["outputs"].addOutput( - "beauty", - IECoreScene.Output( - "$renderDirectory/test.####.exr", - "exr", - "rgba", - {} - ) - ) - - s["render"] = GafferArnold.ArnoldRender() - s["render"]["in"].setInput( s["outputs"]["out"] ) - s["render"]["fileName"].setValue( "$assDirectory/test.####.ass" ) - s["render"]["mode"].setValue( s["render"].Mode.SceneDescriptionMode ) - - self.assertFalse( ( self.temporaryDirectory() / "renderTests" ).exists() ) - self.assertFalse( ( self.temporaryDirectory() / "assTests" ).exists() ) - self.assertFalse( ( self.temporaryDirectory() / "assTests" / "test.0001.ass" ).exists() ) - - s["fileName"].setValue( self.temporaryDirectory() / "test.gfr" ) - - with s.context() : - s["render"]["task"].execute() - - self.assertTrue( ( self.temporaryDirectory() / "renderTests" ).exists() ) - self.assertTrue( ( self.temporaryDirectory() / "assTests" ).exists()) - self.assertTrue( ( self.temporaryDirectory() / "assTests"/ "test.0001.ass" ).exists() ) - - # check it can cope with everything already existing - - with s.context() : - s["render"]["task"].execute() - - self.assertTrue( ( self.temporaryDirectory() / "renderTests" ).exists() ) - self.assertTrue( ( self.temporaryDirectory() / "assTests" ).exists() ) - self.assertTrue( ( self.temporaryDirectory() / "assTests" / "test.0001.ass" ).exists() ) - def testWedge( self ) : s = Gaffer.ScriptNode() diff --git a/python/GafferArnoldTest/InteractiveArnoldRenderTest.py b/python/GafferArnoldTest/InteractiveArnoldRenderTest.py index e8ac5a97fac..66b977edcd8 100644 --- a/python/GafferArnoldTest/InteractiveArnoldRenderTest.py +++ b/python/GafferArnoldTest/InteractiveArnoldRenderTest.py @@ -56,6 +56,7 @@ class InteractiveArnoldRenderTest( GafferSceneTest.InteractiveRenderTest ) : interactiveRenderNodeClass = GafferArnold.InteractiveArnoldRender + renderer = "Arnold" # Arnold outputs licensing warnings that would cause failures failureMessageLevel = IECore.MessageHandler.Level.Error diff --git a/python/GafferCyclesTest/CyclesRenderTest.py b/python/GafferCyclesTest/CyclesRenderTest.py new file mode 100644 index 00000000000..d0f73c1b141 --- /dev/null +++ b/python/GafferCyclesTest/CyclesRenderTest.py @@ -0,0 +1,46 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import unittest + +import GafferSceneTest + +class CyclesRenderTest( GafferSceneTest.RenderTest ) : + + renderer = "Cycles" + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferCyclesTest/InteractiveCyclesRenderTest.py b/python/GafferCyclesTest/InteractiveCyclesRenderTest.py index 0295dada824..7fb83a1171a 100644 --- a/python/GafferCyclesTest/InteractiveCyclesRenderTest.py +++ b/python/GafferCyclesTest/InteractiveCyclesRenderTest.py @@ -45,6 +45,7 @@ class InteractiveCyclesRenderTest( GafferSceneTest.InteractiveRenderTest ) : interactiveRenderNodeClass = GafferCycles.InteractiveCyclesRender + renderer = "Cycles" @unittest.skip( "Resolution edits not supported yet" ) def testEditResolution( self ) : diff --git a/python/GafferCyclesTest/__init__.py b/python/GafferCyclesTest/__init__.py index 3adb106dbec..45062d50a20 100644 --- a/python/GafferCyclesTest/__init__.py +++ b/python/GafferCyclesTest/__init__.py @@ -39,6 +39,7 @@ from .ModuleTest import ModuleTest from .CyclesLightTest import CyclesLightTest from .CyclesShaderTest import CyclesShaderTest +from .CyclesRenderTest import CyclesRenderTest from .IECoreCyclesPreviewTest import * diff --git a/python/GafferDelightTest/DelightRenderTest.py b/python/GafferDelightTest/DelightRenderTest.py index 6072c29be69..d77162b75e9 100644 --- a/python/GafferDelightTest/DelightRenderTest.py +++ b/python/GafferDelightTest/DelightRenderTest.py @@ -34,76 +34,14 @@ # ########################################################################## -import pathlib import unittest -import IECore -import IECoreScene - -import Gaffer -import GafferScene import GafferSceneTest -import GafferDelight - -class DelightRenderTest( GafferSceneTest.SceneTestCase ) : - - def testSceneDescriptionMode( self ) : - - plane = GafferScene.Plane() - render = GafferDelight.DelightRender() - render["in"].setInput( plane["out"] ) - render["mode"].setValue( render.Mode.SceneDescriptionMode ) - render["fileName"].setValue( self.temporaryDirectory() / "test.nsi" ) - - render["task"].execute() - self.assertTrue( pathlib.Path( render["fileName"].getValue() ).exists() ) - - def testRenderMode( self ) : - - plane = GafferScene.Plane() - - outputs = GafferScene.Outputs() - outputs.addOutput( - "beauty", - IECoreScene.Output( - str( self.temporaryDirectory() / "test.exr" ), - "exr", - "rgba", - {} - ) - ) - - render = GafferDelight.DelightRender() - render["in"].setInput( outputs["out"] ) - render["mode"].setValue( render.Mode.RenderMode ) - - render["task"].execute() - self.assertTrue( ( self.temporaryDirectory() / "test.exr" ).exists() ) - - def testSceneTranslationOnly( self ) : - - plane = GafferScene.Plane() - - outputs = GafferScene.Outputs() - outputs.addOutput( - "beauty", - IECoreScene.Output( - str( self.temporaryDirectory() / "test.exr" ), - "exr", - "rgba", - {} - ) - ) - - render = GafferDelight.DelightRender() - render["in"].setInput( outputs["out"] ) - render["mode"].setValue( render.Mode.RenderMode ) - with Gaffer.Context() as context : - context["scene:render:sceneTranslationOnly"] = IECore.BoolData( True ) - render["task"].execute() +class DelightRenderTest( GafferSceneTest.RenderTest ) : - self.assertFalse( ( self.temporaryDirectory() / "test.exr" ).exists() ) + renderer = "3Delight" + sceneDescriptionSuffix = ".nsi" if __name__ == "__main__": unittest.main() diff --git a/python/GafferDelightTest/InteractiveDelightRenderTest.py b/python/GafferDelightTest/InteractiveDelightRenderTest.py index 6644a90082d..457243e2214 100644 --- a/python/GafferDelightTest/InteractiveDelightRenderTest.py +++ b/python/GafferDelightTest/InteractiveDelightRenderTest.py @@ -48,6 +48,7 @@ class InteractiveDelightRenderTest( GafferSceneTest.InteractiveRenderTest ) : interactiveRenderNodeClass = GafferDelight.InteractiveDelightRender + renderer = "3Delight" # Temporarily disable this test (which is implemented in the # base class) because it fails. The issue is that we're automatically diff --git a/python/GafferSceneTest/IECoreScenePreviewTest/RendererTest.py b/python/GafferSceneTest/IECoreScenePreviewTest/RendererTest.py new file mode 100644 index 00000000000..ed45cdf1694 --- /dev/null +++ b/python/GafferSceneTest/IECoreScenePreviewTest/RendererTest.py @@ -0,0 +1,81 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import unittest + +import GafferTest +import GafferScene + +class RendererTest( GafferTest.TestCase ) : + + def testReentrantPythonFactory( self ) : + + # Check that we can register a renderer alias from Python, by forwarding to some + # other creator that is registered dynamically by the first one. We can use this + # to implement delayed loading of the true renderer modules. + + self.assertNotIn( "Test", GafferScene.Private.IECoreScenePreview.Renderer.types() ) + + self.addCleanup( + GafferScene.Private.IECoreScenePreview.Renderer.deregisterType, "Test" + ) + + def creator1( renderType, fileName, messageHandler ) : + + GafferScene.Private.IECoreScenePreview.Renderer.registerType( "Test", creator2 ) + return GafferScene.Private.IECoreScenePreview.Renderer.create( "Test", renderType, fileName, messageHandler ) + + def creator2( renderType, fileName, messageHandler ) : + + return GafferScene.Private.IECoreScenePreview.Renderer.create( "Capturing", renderType, fileName, messageHandler ) + + GafferScene.Private.IECoreScenePreview.Renderer.registerType( "Test", creator1 ) + self.assertIn( "Test", GafferScene.Private.IECoreScenePreview.Renderer.types() ) + + self.assertIsInstance( + GafferScene.Private.IECoreScenePreview.Renderer.create( "Test", GafferScene.Private.IECoreScenePreview.Renderer.RenderType.Interactive ), + GafferScene.Private.IECoreScenePreview.CapturingRenderer + ) + + self.assertIn( "Test", GafferScene.Private.IECoreScenePreview.Renderer.types() ) + + self.assertIsInstance( + GafferScene.Private.IECoreScenePreview.Renderer.create( "Test", GafferScene.Private.IECoreScenePreview.Renderer.RenderType.Interactive ), + GafferScene.Private.IECoreScenePreview.CapturingRenderer + ) + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferSceneTest/IECoreScenePreviewTest/__init__.py b/python/GafferSceneTest/IECoreScenePreviewTest/__init__.py index 9849102ea6a..16d8ef228c7 100644 --- a/python/GafferSceneTest/IECoreScenePreviewTest/__init__.py +++ b/python/GafferSceneTest/IECoreScenePreviewTest/__init__.py @@ -37,6 +37,7 @@ from .CapturingRendererTest import CapturingRendererTest from .CompoundRendererTest import CompoundRendererTest from .PlaceholderTest import PlaceholderTest +from .RendererTest import RendererTest if __name__ == "__main__": import unittest diff --git a/python/GafferSceneTest/InteractiveRenderTest.py b/python/GafferSceneTest/InteractiveRenderTest.py index 29943726666..6b9d82ea7d3 100644 --- a/python/GafferSceneTest/InteractiveRenderTest.py +++ b/python/GafferSceneTest/InteractiveRenderTest.py @@ -53,9 +53,12 @@ # rather than GafferScene.InteractiveRender, which we hope to phase out. class InteractiveRenderTest( GafferSceneTest.SceneTestCase ) : - # Derived classes should set cls.interactiveRenderNodeClass to - # the class of their interactive render node + ## \todo Phase out InteractiveRender subclasses and just use the `renderer` + # field below. interactiveRenderNodeClass = None + # Derived classes should set `cls.renderer` to the type of + # renderer to be tested. + renderer = None @classmethod def setUpClass( cls ) : @@ -2151,6 +2154,51 @@ def testEditCropWindow( self ) : self.assertNotIn( "gaffer:isRendering", script["catalogue"]["out"].metadata() ) + def testRendererOption( self ): + + script = Gaffer.ScriptNode() + + script["outputs"] = GafferScene.Outputs() + script["outputs"].addOutput( + "beauty", + IECoreScene.Output( + "test", + "ieDisplay", + "rgba", + { + "driverType" : "ImageDisplayDriver", + "handle" : "testRendererOption", + } + ) + ) + + script["customOptions"] = GafferScene.CustomOptions() + script["customOptions"]["in"].setInput( script["outputs"]["out"] ) + + script["renderer"] = self._createInteractiveRender( useNodeClass = False ) + script["renderer"]["renderer"].setValue( "" ) + script["renderer"]["in"].setInput( script["customOptions"]["out"] ) + + # No renderer specified yet, so if we start the render we don't + # get an image. + + script["renderer"]["state"].setValue( script["renderer"].State.Running ) + time.sleep( 1.0 ) + self.assertIsNone( IECoreImage.ImageDisplayDriver.storedImage( "testRendererOption" ) ) + self.ignoreMessage( IECore.Msg.Level.Error, "InteractiveRender", "`render:defaultRenderer` option not set" ) + + # Set renderer option and start again. We should now get an image. + + script["renderer"]["state"].setValue( script["renderer"].State.Stopped ) + script["customOptions"]["options"].addChild( + Gaffer.NameValuePlug( "render:defaultRenderer", self.renderer ) + ) + script["renderer"]["state"].setValue( script["renderer"].State.Running ) + time.sleep( 1.0 ) + + image = IECoreImage.ImageDisplayDriver.storedImage( "testRendererOption" ) + self.assertIsInstance( image, IECoreImage.ImagePrimitive ) + def tearDown( self ) : GafferSceneTest.SceneTestCase.tearDown( self ) @@ -2160,10 +2208,14 @@ def tearDown( self ) : ## Should be used in test cases to create an InteractiveRender node # suitably configured for error reporting. If failOnError is # True, then the node's error signal will cause the test to fail. - def _createInteractiveRender( self, failOnError = True ) : - - assert( issubclass( self.interactiveRenderNodeClass, GafferScene.InteractiveRender ) ) - node = self.interactiveRenderNodeClass() + def _createInteractiveRender( self, failOnError = True, useNodeClass = True ) : + + if useNodeClass : + assert( issubclass( self.interactiveRenderNodeClass, GafferScene.InteractiveRender ) ) + node = self.interactiveRenderNodeClass() + else : + node = GafferScene.InteractiveRender() + node["renderer"].setValue( self.renderer ) if failOnError : diff --git a/python/GafferSceneTest/OpenGLRenderTest.py b/python/GafferSceneTest/OpenGLRenderTest.py index cab30b1361f..f3bd7441d4e 100644 --- a/python/GafferSceneTest/OpenGLRenderTest.py +++ b/python/GafferSceneTest/OpenGLRenderTest.py @@ -49,9 +49,11 @@ import GafferSceneTest @unittest.skipIf( GafferTest.inCI(), "OpenGL not set up" ) -class OpenGLRenderTest( GafferSceneTest.SceneTestCase ) : +class OpenGLRenderTest( GafferSceneTest.RenderTest ) : - def test( self ) : + renderer = "OpenGL" + + def testTextureFromImagePlug( self ) : self.assertFalse( ( self.temporaryDirectory() / "test.exr" ).exists() ) @@ -106,80 +108,5 @@ def test( self ) : self.assertAlmostEqual( imageSampler["color"]["g"].getValue(), 0.666666, delta = 0.001 ) self.assertEqual( imageSampler["color"]["b"].getValue(), 0 ) - def testOutputDirectoryCreation( self ) : - - s = Gaffer.ScriptNode() - s["variables"].addChild( Gaffer.NameValuePlug( "renderDirectory", ( self.temporaryDirectory() / "openGLRenderTest" ).as_posix() ) ) - - s["plane"] = GafferScene.Plane() - - s["outputs"] = GafferScene.Outputs() - s["outputs"]["in"].setInput( s["plane"]["out"] ) - s["outputs"].addOutput( - "beauty", - IECoreScene.Output( - "$renderDirectory/test.####.exr", - "exr", - "rgba", - {} - ) - ) - - s["render"] = GafferScene.OpenGLRender() - s["render"]["in"].setInput( s["outputs"]["out"] ) - - self.assertFalse( ( self.temporaryDirectory() / "openGLRenderTest" ).exists() ) - self.assertFalse( ( self.temporaryDirectory() / "openGLRenderTest" / "test.0001.exr" ).exists() ) - - with s.context() : - s["render"]["task"].execute() - - self.assertTrue( ( self.temporaryDirectory() / "openGLRenderTest" ).exists() ) - self.assertTrue( ( self.temporaryDirectory() / "openGLRenderTest" / "test.0001.exr" ).exists() ) - - def testHash( self ) : - - c = Gaffer.Context() - c.setFrame( 1 ) - c2 = Gaffer.Context() - c2.setFrame( 2 ) - - s = Gaffer.ScriptNode() - s["plane"] = GafferScene.Plane() - s["outputs"] = GafferScene.Outputs() - s["outputs"]["in"].setInput( s["plane"]["out"] ) - s["outputs"].addOutput( "beauty", IECoreScene.Output( "$renderDirectory/test.####.exr", "exr", "rgba", {} ) ) - s["render"] = GafferScene.OpenGLRender() - - # no input scene produces no effect - self.assertEqual( s["render"].hash( c ), IECore.MurmurHash() ) - - # now theres an scene to render, we get some output - s["render"]["in"].setInput( s["outputs"]["out"] ) - self.assertNotEqual( s["render"].hash( c ), IECore.MurmurHash() ) - - # output varies by time - self.assertNotEqual( s["render"].hash( c ), s["render"].hash( c2 ) ) - - # output varies by new Context entries - current = s["render"].hash( c ) - c["renderDirectory"] = ( self.temporaryDirectory() / "openGLRenderTest" ).as_posix() - self.assertNotEqual( s["render"].hash( c ), current ) - - # output varies by changed Context entries - current = s["render"].hash( c ) - c["renderDirectory"] = ( self.temporaryDirectory() / "openGLRenderTest2" ).as_posix() - self.assertNotEqual( s["render"].hash( c ), current ) - - # output doesn't vary by ui Context entries - current = s["render"].hash( c ) - c["ui:something"] = "alterTheUI" - self.assertEqual( s["render"].hash( c ), current ) - - # also varies by input node - current = s["render"].hash( c ) - s["render"]["in"].setInput( s["plane"]["out"] ) - self.assertNotEqual( s["render"].hash( c ), current ) - if __name__ == "__main__": unittest.main() diff --git a/python/GafferSceneTest/RenderTest.py b/python/GafferSceneTest/RenderTest.py new file mode 100644 index 00000000000..e7f4f5626c5 --- /dev/null +++ b/python/GafferSceneTest/RenderTest.py @@ -0,0 +1,286 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of John Haddon nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import pathlib +import subprocess +import unittest + +import IECore +import IECoreScene + +import Gaffer +import GafferScene +import GafferSceneTest + +## \todo Transfer more tests from subclasses to this base class, so that we +# run them for all renderers. +class RenderTest( GafferSceneTest.SceneTestCase ) : + + # Derived classes should set `renderer` to the name of the renderer + # to be tested. + renderer = None + # And set this to the file extension used for scene description, if + # scene description is supported. + sceneDescriptionSuffix = None + + @classmethod + def setUpClass( cls ) : + + GafferSceneTest.SceneTestCase.setUpClass() + + if cls.renderer is None : + # We expect derived classes to set the renderer, and will + # run the tests there. + raise unittest.SkipTest( "No renderer available" ) + + def testOutputDirectoryCreation( self ) : + + s = Gaffer.ScriptNode() + s["variables"].addChild( Gaffer.NameValuePlug( "renderDirectory", ( self.temporaryDirectory() / "renderTest" ).as_posix() ) ) + + s["plane"] = GafferScene.Plane() + + s["outputs"] = GafferScene.Outputs() + s["outputs"]["in"].setInput( s["plane"]["out"] ) + s["outputs"].addOutput( + "beauty", + IECoreScene.Output( + "$renderDirectory/test.####.exr", + "exr", + "rgba", + {} + ) + ) + + s["render"] = GafferScene.Render() + s["render"]["renderer"].setValue( self.renderer ) + s["render"]["in"].setInput( s["outputs"]["out"] ) + + self.assertFalse( ( self.temporaryDirectory() / "renderTest" ).exists() ) + self.assertFalse( ( self.temporaryDirectory() / "renderTest" / "test.0001.exr" ).exists() ) + + for i in range( 0, 2 ) : # Test twice, to check we can cope with the directory already existing. + + with s.context() : + s["render"]["task"].execute() + + self.assertTrue( ( self.temporaryDirectory() / "renderTest" ).exists() ) + self.assertTrue( ( self.temporaryDirectory() / "renderTest" / "test.0001.exr" ).exists() ) + + def testHash( self ) : + + c = Gaffer.Context() + c.setFrame( 1 ) + c2 = Gaffer.Context() + c2.setFrame( 2 ) + + s = Gaffer.ScriptNode() + s["plane"] = GafferScene.Plane() + s["outputs"] = GafferScene.Outputs() + s["outputs"]["in"].setInput( s["plane"]["out"] ) + s["outputs"].addOutput( "beauty", IECoreScene.Output( "$renderDirectory/test.####.exr", "exr", "rgba", {} ) ) + s["render"] = GafferScene.Render() + s["render"]["renderer"].setValue( self.renderer ) + + # no input scene produces no effect + self.assertEqual( s["render"].hash( c ), IECore.MurmurHash() ) + + # now theres an scene to render, we get some output + s["render"]["in"].setInput( s["outputs"]["out"] ) + self.assertNotEqual( s["render"].hash( c ), IECore.MurmurHash() ) + + # output varies by time + self.assertNotEqual( s["render"].hash( c ), s["render"].hash( c2 ) ) + + # output varies by new Context entries + current = s["render"].hash( c ) + c["renderDirectory"] = ( self.temporaryDirectory() / "renderTest" ).as_posix() + self.assertNotEqual( s["render"].hash( c ), current ) + + # output varies by changed Context entries + current = s["render"].hash( c ) + c["renderDirectory"] = ( self.temporaryDirectory() / "renderTest2" ).as_posix() + self.assertNotEqual( s["render"].hash( c ), current ) + + # output doesn't vary by ui Context entries + current = s["render"].hash( c ) + c["ui:something"] = "alterTheUI" + self.assertEqual( s["render"].hash( c ), current ) + + # also varies by input node + current = s["render"].hash( c ) + s["render"]["in"].setInput( s["plane"]["out"] ) + self.assertNotEqual( s["render"].hash( c ), current ) + + def testSceneTranslationOnly( self ) : + + outputs = GafferScene.Outputs() + outputs.addOutput( + "beauty", + IECoreScene.Output( + str( self.temporaryDirectory() / "test.exr" ), + "exr", + "rgba", + {} + ) + ) + + render = GafferScene.Render() + render["in"].setInput( outputs["out"] ) + render["mode"].setValue( render.Mode.RenderMode ) + render["renderer"].setValue( self.renderer ) + + with Gaffer.Context() as context : + context["scene:render:sceneTranslationOnly"] = IECore.BoolData( True ) + render["task"].execute() + + self.assertFalse( ( self.temporaryDirectory() / "test.exr" ).exists() ) + + def testRenderMode( self ) : + + outputs = GafferScene.Outputs() + outputs.addOutput( + "beauty", + IECoreScene.Output( + str( self.temporaryDirectory() / "test.exr" ), + "exr", + "rgba", + {} + ) + ) + + render = GafferScene.Render() + render["in"].setInput( outputs["out"] ) + render["mode"].setValue( render.Mode.RenderMode ) + render["renderer"].setValue( self.renderer ) + + render["task"].execute() + self.assertTrue( ( self.temporaryDirectory() / "test.exr" ).exists() ) + + def testSceneDescriptionMode( self ) : + + if self.sceneDescriptionSuffix is None : + raise unittest.SkipTest( "Scene description not supported" ) + + plane = GafferScene.Plane() + + render = GafferScene.Render() + render["in"].setInput( plane["out"] ) + render["mode"].setValue( render.Mode.SceneDescriptionMode ) + render["fileName"].setValue( ( self.temporaryDirectory() / "subdirectory" / f"test{self.sceneDescriptionSuffix}" ) ) + render["renderer"].setValue( self.renderer ) + + for i in range( 0, 2 ) : # Test twice, to check we can cope with the directory already existing. + + render["task"].execute() + self.assertTrue( pathlib.Path( render["fileName"].getValue() ).exists() ) + + def testExecute( self ) : + + if self.sceneDescriptionSuffix is None : + raise unittest.SkipTest( "Scene description not supported" ) + + s = Gaffer.ScriptNode() + + s["plane"] = GafferScene.Plane() + s["render"] = GafferScene.Render() + s["render"]["mode"].setValue( s["render"].Mode.SceneDescriptionMode ) + s["render"]["renderer"].setValue( self.renderer ) + s["render"]["fileName"].setValue( ( self.temporaryDirectory() / f"test.#{self.sceneDescriptionSuffix}" ) ) + s["render"]["in"].setInput( s["plane"]["out"] ) + + scriptFileName = self.temporaryDirectory() / "test.gfr" + s["fileName"].setValue( scriptFileName ) + s.save() + + subprocess.check_call( + f"gaffer execute {scriptFileName} -frames 1-3", + shell=True, + ) + + for i in range( 1, 4 ) : + self.assertTrue( ( self.temporaryDirectory() / f"test.{i}{self.sceneDescriptionSuffix}" ).exists() ) + + def testRendererOption( self ) : + + outputs = GafferScene.Outputs() + outputs.addOutput( + "beauty", + IECoreScene.Output( + str( self.temporaryDirectory() / "test.exr" ), + "exr", + "rgba", + {} + ) + ) + + customOptions = GafferScene.CustomOptions() + customOptions["in"].setInput( outputs["out"] ) + customOptions["options"].addChild( Gaffer.NameValuePlug( "render:defaultRenderer", self.renderer ) ) + + render = GafferScene.Render() + render["in"].setInput( customOptions["out"] ) + render["mode"].setValue( render.Mode.RenderMode ) + self.assertEqual( render["renderer"].getValue(), "" ) + + render["task"].execute() + self.assertTrue( ( self.temporaryDirectory() / "test.exr" ).exists() ) + + def testNoRenderer( self ) : + + outputs = GafferScene.Outputs() + outputs.addOutput( + "beauty", + IECoreScene.Output( + str( self.temporaryDirectory() / "test.exr" ), + "exr", + "rgba", + {} + ) + ) + + render = GafferScene.Render() + render["in"].setInput( outputs["out"] ) + render["mode"].setValue( render.Mode.RenderMode ) + self.assertEqual( render["renderer"].getValue(), "" ) + + self.assertEqual( render["task"].hash(), IECore.MurmurHash() ) + + render["task"].execute() + self.assertFalse( ( self.temporaryDirectory() / "test.exr" ).exists() ) + +if __name__ == "__main__": + unittest.main() diff --git a/python/GafferSceneTest/__init__.py b/python/GafferSceneTest/__init__.py index 8b56ed9c103..5aa14a8e898 100644 --- a/python/GafferSceneTest/__init__.py +++ b/python/GafferSceneTest/__init__.py @@ -67,6 +67,7 @@ from .ScenePathTest import ScenePathTest from .LightTest import LightTest from .OpenGLShaderTest import OpenGLShaderTest +from .RenderTest import RenderTest from .OpenGLRenderTest import OpenGLRenderTest from .TransformTest import TransformTest from .AimConstraintTest import AimConstraintTest diff --git a/python/GafferSceneUI/InteractiveRenderUI.py b/python/GafferSceneUI/InteractiveRenderUI.py index 9210a73c613..dd61081092f 100644 --- a/python/GafferSceneUI/InteractiveRenderUI.py +++ b/python/GafferSceneUI/InteractiveRenderUI.py @@ -41,6 +41,7 @@ import Gaffer import GafferImage import GafferScene +import GafferSceneUI import GafferUI import GafferImageUI @@ -374,6 +375,13 @@ def _updateFromValues( self, values, exception ) : # Metadata for InteractiveRender node. ########################################################################## +def __rendererPresetNames( plug ) : + + return IECore.StringVectorData( [ + x for x in GafferSceneUI.RenderUI.rendererPresetNames( plug ) + if x != "OpenGL" + ] ) + Gaffer.Metadata.registerNode( GafferScene.InteractiveRender, @@ -409,9 +417,20 @@ def _updateFromValues( self, values, exception ) : "description", """ - The renderer to use. + The renderer to use. Default mode uses the `render:defaultRenderer` option from + the input scene globals to choose the renderer. This can be authored using + the StandardOptions node. + + > Note : Changing renderer currently requires that the current render is + > manually stopped and restarted. """, + "plugValueWidget:type", "GafferSceneUI.RenderUI.RendererPlugValueWidget", + + "preset:Default", "", + "presetNames", __rendererPresetNames, + "presetValues", __rendererPresetNames, + ], "state" : [ diff --git a/python/GafferSceneUI/RenderUI.py b/python/GafferSceneUI/RenderUI.py index 4abd5089acb..57c523692b0 100644 --- a/python/GafferSceneUI/RenderUI.py +++ b/python/GafferSceneUI/RenderUI.py @@ -34,9 +34,24 @@ # ########################################################################## +import IECore + import Gaffer +import GafferUI import GafferScene +from GafferUI.PlugValueWidget import sole + +def rendererPresetNames( plug ) : + + blacklist = { "Capturing" } + return IECore.StringVectorData( + sorted( + t for t in GafferScene.Private.IECoreScenePreview.Renderer.types() + if t not in blacklist + ) + ) + Gaffer.Metadata.registerNode( GafferScene.Render, @@ -68,9 +83,17 @@ "description", """ - The renderer to use. + The renderer to use. Default mode uses the `render:defaultRenderer` option from + the input scene globals to choose the renderer. This can be authored using + the StandardOptions node. """, + "plugValueWidget:type", "GafferSceneUI.RenderUI.RendererPlugValueWidget", + + "preset:Default", "", + "presetNames", rendererPresetNames, + "presetValues", rendererPresetNames, + ], "mode" : [ @@ -112,3 +135,53 @@ } ) + +# Augments PresetsPlugValueWidget label with the renderer name +# when preset is "Default". Since this involves computing the +# scene globals, we do the work in the background via an auxiliary +# plug passed to `_valuesForUpdate()`. +class RendererPlugValueWidget( GafferUI.PresetsPlugValueWidget ) : + + def __init__( self, plugs, **kw ) : + + GafferUI.PresetsPlugValueWidget.__init__( self, plugs, **kw ) + + @staticmethod + def _valuesForUpdate( plugs, auxiliaryPlugs ) : + + presets = GafferUI.PresetsPlugValueWidget._valuesForUpdate( plugs, [ [] for p in plugs ] ) + + result = [] + for preset, globalsPlugs in zip( presets, auxiliaryPlugs ) : + + defaultRenderer = "" + if len( globalsPlugs ) and preset == "Default" : + with IECore.IgnoredExceptions( Gaffer.ProcessException ) : + defaultRenderer = globalsPlugs[0].getValue().get( "option:render:defaultRenderer" ) + defaultRenderer = defaultRenderer.value if defaultRenderer is not None else "" + + result.append( { + "preset" : preset, + "defaultRenderer" : defaultRenderer + } ) + + return result + + def _updateFromValues( self, values, exception ) : + + GafferUI.PresetsPlugValueWidget._updateFromValues( self, [ v["preset"] for v in values ], exception ) + + if self.menuButton().getText() == "Default" : + defaultRenderer = sole( v["defaultRenderer"] for v in values ) + self.menuButton().setText( + "Default ({})".format( + defaultRenderer if defaultRenderer else + ( "None" if defaultRenderer == "" else "---" ) + ) + ) + + def _auxiliaryPlugs( self, plug ) : + + node = plug.node() + if isinstance( node, ( GafferScene.Render, GafferScene.InteractiveRender ) ) : + return [ node["in"]["globals"] ] diff --git a/python/GafferSceneUI/StandardOptionsUI.py b/python/GafferSceneUI/StandardOptionsUI.py index c40259d4445..ee2c5f80df0 100644 --- a/python/GafferSceneUI/StandardOptionsUI.py +++ b/python/GafferSceneUI/StandardOptionsUI.py @@ -42,6 +42,7 @@ import Gaffer import GafferUI import GafferScene +import GafferSceneUI from GafferUI.PlugValueWidget import sole @@ -77,6 +78,13 @@ def __cameraSummary( plug ) : return ", ".join( info ) +def __rendererSummary( plug ) : + + if plug["defaultRenderer"]["enabled"].getValue() : + return plug["defaultRenderer"]["value"].getValue() + + return "" + def __renderSetSummary( plug ) : info = [] @@ -123,6 +131,7 @@ def __statisticsSummary( plug ) : "options" : [ "layout:section:Camera:summary", __cameraSummary, + "layout:section:Renderer:summary", __rendererSummary, "layout:section:Render Set:summary", __renderSetSummary, "layout:section:Motion Blur:summary", __motionBlurSummary, "layout:section:Statistics:summary", __statisticsSummary, @@ -340,7 +349,31 @@ def __statisticsSummary( plug ) : "layout:section", "Camera", ], - # Purpose + # Renderer + + "options.defaultRenderer" : [ + + "description", + """ + Specifies the default renderer to be used by the Render and + InteractiveRender nodes. + """, + + "label", "Default Renderer", + "layout:section", "Renderer", + + ], + + "options.defaultRenderer.value" : [ + + "plugValueWidget:type", "GafferUI.PresetsPlugValueWidget", + "preset:None", "", + "presetNames", GafferSceneUI.RenderUI.rendererPresetNames, + "presetValues", GafferSceneUI.RenderUI.rendererPresetNames, + + ], + + # Render Set "options.includedPurposes" : [ diff --git a/python/GafferSceneUI/__init__.py b/python/GafferSceneUI/__init__.py index 078fe7c6043..a00e317b185 100644 --- a/python/GafferSceneUI/__init__.py +++ b/python/GafferSceneUI/__init__.py @@ -63,6 +63,7 @@ from . import OptionsUI from . import OpenGLAttributesUI from . import SceneWriterUI +from . import RenderUI from . import StandardOptionsUI from . import StandardAttributesUI from . import ShaderUI @@ -125,7 +126,6 @@ from . import AttributeVisualiserUI from . import FilterProcessorUI from . import MeshToPointsUI -from . import RenderUI from . import ShaderBallUI from . import ShaderTweaksUI from . import CameraTweaksUI diff --git a/src/GafferScene/IECoreScenePreview/Renderer.cpp b/src/GafferScene/IECoreScenePreview/Renderer.cpp index 509aa107c52..a72ee03890b 100644 --- a/src/GafferScene/IECoreScenePreview/Renderer.cpp +++ b/src/GafferScene/IECoreScenePreview/Renderer.cpp @@ -48,19 +48,17 @@ using namespace IECoreScenePreview; namespace { -using Creator = Renderer::Ptr (*)( Renderer::RenderType, const std::string &, const IECore::MessageHandlerPtr & ); - vector &types() { static vector g_types; return g_types; } -using CreatorMap = map; +using CreatorMap = map; CreatorMap &creators() { - static CreatorMap g_creators; - return g_creators; + static CreatorMap *g_creators = new CreatorMap; + return *g_creators; } } // namespace @@ -112,19 +110,24 @@ Renderer::Ptr Renderer::create( const IECore::InternedString &type, RenderType r { return nullptr; } - return it->second( renderType, fileName, messageHandler ); + // Take copy of creator, since it is allowed to do a switcheroo + // by calling `registerType( name, theRealCreator )`. + Creator creator = it->second; + return creator( renderType, fileName, messageHandler ); } - -void Renderer::registerType( const IECore::InternedString &typeName, Ptr (*creator)( RenderType, const std::string &, const IECore::MessageHandlerPtr & ) ) +void Renderer::registerType( const IECore::InternedString &typeName, Creator creator ) { - CreatorMap &c = creators(); - CreatorMap::iterator it = c.find( typeName ); - if( it != c.end() ) - { - it->second = creator; - return; - } - c[typeName] = creator; + creators()[typeName] = creator; ::types().push_back( typeName ); } + +void Renderer::deregisterType( const IECore::InternedString &typeName ) +{ + creators().erase( typeName ); + auto &t = ::types(); + t.erase( + std::remove( t.begin(), t.end(), typeName ), + t.end() + ); +} diff --git a/src/GafferScene/InteractiveRender.cpp b/src/GafferScene/InteractiveRender.cpp index c0f891e1759..57bb9d5d278 100644 --- a/src/GafferScene/InteractiveRender.cpp +++ b/src/GafferScene/InteractiveRender.cpp @@ -83,6 +83,8 @@ PendingUpdates &pendingUpdates() return *p; } +const InternedString g_rendererOptionName( "option:render:defaultRenderer" ); + } // anon namespace // A thread-safe message handler for render messaging @@ -314,8 +316,30 @@ void InteractiveRender::update() { m_messageHandler->clear(); + std::string rendererType = rendererPlug()->getValue(); + if( rendererType.empty() ) + { + /// \todo It'd be great if we could deal with live edits to the `render:defaultRenderer` + /// option, to switch renderer on the fly. The best way of doing this is probably + /// to move the renderer creation to the RenderController. That approach would also + /// allow the RenderController to recreate the renderer when unsupported option edits + /// are made - for instance, changing `cycles:device`. + ConstCompoundObjectPtr globals = adaptedInPlug()->globals(); + if( auto rendererData = globals->member( g_rendererOptionName ) ) + { + rendererType = rendererData->readable(); + } + if( rendererType.empty() ) + { + m_messageHandler->handle( + IECore::Msg::Error, "InteractiveRender", "`render:defaultRenderer` option not set" + ); + return; + } + } + m_renderer = IECoreScenePreview::Renderer::create( - rendererPlug()->getValue(), + rendererType, IECoreScenePreview::Renderer::Interactive, "", m_messageHandler.get() diff --git a/src/GafferScene/Render.cpp b/src/GafferScene/Render.cpp index 1dd32b7cf46..43d8d572d91 100644 --- a/src/GafferScene/Render.cpp +++ b/src/GafferScene/Render.cpp @@ -62,6 +62,7 @@ namespace { const InternedString g_performanceMonitorOptionName( "option:render:performanceMonitor" ); +const InternedString g_rendererOptionName( "option:render:defaultRenderer" ); const InternedString g_sceneTranslationOnlyContextName( "scene:render:sceneTranslationOnly" ); struct RenderScope : public Context::EditableScope @@ -206,10 +207,18 @@ IECore::MurmurHash Render::hash( const Gaffer::Context *context ) const RenderScope renderScope( context ); - const std::string rendererType = rendererPlug()->getValue(); + std::string rendererType = rendererPlug()->getValue(); if( rendererType.empty() ) { - return IECore::MurmurHash(); + ConstCompoundObjectPtr globals = adaptedInPlug()->globals(); + if( auto rendererData = globals->member( g_rendererOptionName ) ) + { + rendererType = rendererData->readable(); + } + if( rendererType.empty() ) + { + return IECore::MurmurHash(); + } } const Mode mode = static_cast( modePlug()->getValue() ); @@ -219,6 +228,12 @@ IECore::MurmurHash Render::hash( const Gaffer::Context *context ) const return IECore::MurmurHash(); } + /// \todo Since we're computing the globals now (see above), + /// maybe our hash should be the hash of the output definitions? + /// Then we'd know which parts of the context we were sensitive to + /// and wouldn't have such a pessimistic hash that includes all + /// context variables. + IECore::MurmurHash h = TaskNode::hash( context ); h.append( (uint64_t)inPlug()->source() ); h.append( context->hash() ); @@ -260,10 +275,22 @@ void Render::executeInternal( bool flushCaches ) const RenderScope renderScope( Context::current() ); - const std::string rendererType = rendererPlug()->getValue(); + std::string rendererType = rendererPlug()->getValue(); if( rendererType.empty() ) { - return; + /// \todo We're evaluating the globals twice, once here and once in + /// `RenderOptions` below. When we remove the `scene:renderer` context + /// variable, we'll be able to move the RenderOptions here and only do + /// one evaluation. + ConstCompoundObjectPtr globals = adaptedInPlug()->globals(); + if( auto rendererData = globals->member( g_rendererOptionName ) ) + { + rendererType = rendererData->readable(); + } + if( rendererType.empty() ) + { + return; + } } renderScope.set( g_rendererContextName, &rendererType ); diff --git a/src/GafferScene/StandardOptions.cpp b/src/GafferScene/StandardOptions.cpp index 9d3a29732b8..a56edaea43d 100644 --- a/src/GafferScene/StandardOptions.cpp +++ b/src/GafferScene/StandardOptions.cpp @@ -65,6 +65,10 @@ StandardOptions::StandardOptions( const std::string &name ) options->addChild( new Gaffer::NameValuePlug( "render:overscanRight", new FloatPlug( "value", Plug::In, 0.1f, 0.0f, 1.0f ), false, "overscanRight" ) ); options->addChild( new Gaffer::NameValuePlug( "render:depthOfField", new IECore::BoolData( false ), false, "depthOfField" ) ); + // Renderer + + options->addChild( new Gaffer::NameValuePlug( "render:defaultRenderer", new IECore::StringData(), false, "defaultRenderer" ) ); + // Render set options->addChild( new Gaffer::NameValuePlug( "render:includedPurposes", new IECore::StringVectorData( { "default", "render" } ), false, "includedPurposes" ) ); diff --git a/src/GafferSceneModule/RenderBinding.cpp b/src/GafferSceneModule/RenderBinding.cpp index 2977311c460..e75e94b102c 100644 --- a/src/GafferSceneModule/RenderBinding.cpp +++ b/src/GafferSceneModule/RenderBinding.cpp @@ -118,6 +118,29 @@ void interactiveRenderSetContext( InteractiveRender &r, Context &context ) r.setContext( &context ); } +void registerTypeWrapper( const std::string &name, object creator ) +{ + // The function we register will be held and destroyed from C++. + // Wrap it so that we correctly acquire the GIL before the captured + // Python object is destroyed. + auto creatorPtr = std::shared_ptr( + new boost::python::object( creator ), + []( boost::python::object *o ) { + IECorePython::ScopedGILLock gilLock; + delete o; + } + ); + + Renderer::registerType( + name, + [creatorPtr] ( Renderer::RenderType renderType, const std::string &fileName, const IECore::MessageHandlerPtr &messageHandler ) -> Renderer::Ptr { + IECorePython::ScopedGILLock gilLock; + object o = (*creatorPtr)( renderType, fileName, messageHandler ); + return extract( o ); + } + ); +} + list rendererTypes() { std::vector t = Renderer::types(); @@ -524,6 +547,8 @@ void GafferSceneModule::bindRender() renderer + .def( "registerType", ®isterTypeWrapper ) + .def( "deregisterType", &Renderer::deregisterType ) .def( "types", &rendererTypes ) .staticmethod( "types" ) .def( "create", &Renderer::create, ( arg( "type" ), arg( "renderType" ) = Renderer::Batch, arg( "fileName" ) = "", arg( "messageHandler" ) = IECore::MessageHandlerPtr() ) ) diff --git a/startup/GafferScene/renderers.py b/startup/GafferScene/renderers.py new file mode 100644 index 00000000000..fbe5f47f733 --- /dev/null +++ b/startup/GafferScene/renderers.py @@ -0,0 +1,68 @@ +########################################################################## +# +# Copyright (c) 2024, Cinesite VFX Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above +# copyright notice, this list of conditions and the following +# disclaimer. +# +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided with +# the distribution. +# +# * Neither the name of Cinesite VFX Ltd. nor the names of +# any other contributors to this software may be used to endorse or +# promote products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +########################################################################## + +import sys +import functools + +import IECore +import GafferScene + +# Register functions to load renderers on demand. This allows us to find a renderer +# even if the relevant Python module hasn't been imported (in `gaffer execute` for instance). + +def __creator( renderType, fileName, messageHandler, renderer, module ) : + + # Import module to replace ourselves with the true renderer creation function. + __import__( module ) + # And then call `create()` again to use it. + return GafferScene.Private.IECoreScenePreview.Renderer.create( renderer, renderType, fileName, messageHandler ) + +for renderer, module in [ + ( "Cycles", "GafferCycles" ), + ( "Arnold", "IECoreArnold" ), + ( "3Delight", "IECoreDelight" ), +] : + if renderer in GafferScene.Private.IECoreScenePreview.Renderer.types() : + # Already registered + continue + if not IECore.SearchPath( sys.path ).find( module ) : + # Renderer not available + continue + + # Register creator that will load module to provide renderer on demand. + GafferScene.Private.IECoreScenePreview.Renderer.registerType( + renderer, functools.partial( __creator, renderer = renderer, module = module ) + ) diff --git a/startup/GafferScene/standardOptions.py b/startup/GafferScene/standardOptions.py index 8665a18c416..b9acb555fdc 100644 --- a/startup/GafferScene/standardOptions.py +++ b/startup/GafferScene/standardOptions.py @@ -110,6 +110,17 @@ """ ) +Gaffer.Metadata.registerValue( "option:render:defaultRenderer", "label", "Renderer" ) +Gaffer.Metadata.registerValue( "option:render:defaultRenderer", "defaultValue", "" ) +Gaffer.Metadata.registerValue( + "option:render:defaultRenderer", + "description", + """ + Specifies the default renderer to be used by the Render and + InteractiveRender nodes. + """ +) + Gaffer.Metadata.registerValue( "option:render:inclusions", "label", "Inclusions" ) Gaffer.Metadata.registerValue( "option:render:inclusions", "defaultValue", IECore.StringData( "/" ) ) Gaffer.Metadata.registerValue( diff --git a/startup/gui/menus.py b/startup/gui/menus.py index 76d3ed15e97..4f8156a208a 100644 --- a/startup/gui/menus.py +++ b/startup/gui/menus.py @@ -345,6 +345,8 @@ def __lightCreator( nodeName, shaderName, shape ) : nodeMenu.append( "/Scene/Passes/Render Passes", GafferScene.RenderPasses, searchText = "RenderPasses" ) nodeMenu.append( "/Scene/Passes/Delete Render Passes", GafferScene.DeleteRenderPasses, searchText = "DeleteRenderPasses" ) nodeMenu.append( "/Scene/Passes/Render Pass Wedge", GafferScene.RenderPassWedge, searchText = "RenderPassWedge" ) +nodeMenu.append( "/Scene/Render/Render", GafferScene.Render ) +nodeMenu.append( "/Scene/Render/Interactive Render", GafferScene.InteractiveRender, searchText = "InteractiveRender" ) # Image nodes diff --git a/startup/gui/renderPassEditor.py b/startup/gui/renderPassEditor.py index 3fc28293af4..ef91f11fbff 100644 --- a/startup/gui/renderPassEditor.py +++ b/startup/gui/renderPassEditor.py @@ -45,6 +45,7 @@ GafferSceneUI.RenderPassEditor.registerOption( "*", "render:exclusions" ) GafferSceneUI.RenderPassEditor.registerOption( "*", "render:additionalLights" ) +GafferSceneUI.RenderPassEditor.registerOption( "*", "render:defaultRenderer", "Render" ) GafferSceneUI.RenderPassEditor.registerOption( "*", "render:camera", "Render" ) GafferSceneUI.RenderPassEditor.registerOption( "*", "render:resolution", "Render" ) GafferSceneUI.RenderPassEditor.registerOption( "*", "render:resolutionMultiplier", "Render" )