examples/foreign-stimulus.py
This is one of the example scripts included
with Shady. These scripts can be run conventionally like any
normal Python script, or you can choose to run them as
interactive tutorials, for example with python -m Shady demo foreign-stimulus
#!/usr/bin/env python
# $BEGIN_SHADY_LICENSE$
#
# This file is part of the Shady project, a Python framework for
# real-time manipulation of psychophysical stimuli for vision science.
#
# Copyright (c) 2017-2022 Jeremy Hill, Scott Mooney
#
# Shady is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/ .
#
# $END_SHADY_LICENSE$
#: How to add "foreign" (non-Shady) stimuli to a World
"""
Shady lets you draw anything you like among its stimuli.
This script demonstrates how you can write a custom class,
for use as a "foreign" (non-Shady) stimulus. The only thing
it needs is a `.Draw()` method that takes no additional
arguments. You are free to use any OpenGL drawing commands
you want in this method (the only caveat being that,
depending on whether you are using "legacy" or "modern"
OpenGL commands, you should ensure that the `World` is created
with `legacy=True` or `legacy=False` appropriately). The
current demo uses certain Shady features as helpers---
specifically the `World` projection matrix, and an `Integral`
instance---but in principle there is no need to refer to Shady:
you can do whatever you want.
This demo requires third-party modules `OpenGL` (from the
package `PyOpenGL`) and `numpy` (from `numpy`).
"""#.
import random
if __name__ == '__main__':
import Shady
Shady.DependencyManagement.Define( 'OpenGL', packageName='pyopengl' )
"""
First deal with the demo's command-line arguments, if any.
Note that we're setting `legacy=True` to ensure that a
legacy OpenGL context is created (or at least legacy-
compatible one) because later we will be using old-fashioned
legacy OpenGL commands.
"""#:
cmdline = Shady.WorldConstructorCommandLine( legacy=True )
cmdline.Help().Finalize()
Shady.Require( 'OpenGL', 'numpy' ) # die with an informative error if either is missing
"""
Create a World:
"""#:
w = Shady.World( **cmdline.opts )
w.perspectiveCameraAngle = 120 # default is 0, which means orthographic
"""
Let's define a Shady stimulus for our foreign stimulus to
interact with. Normally the `PixelRuler()` stimulus is
created at a positive depth to ensure it lies behind most
other stimuli, but here we'll explicitly reset its `.z` to 0
(the default for most stimuli). Shady's convention for
perspective projections is that stimuli at `z=0` are pixel-
for-pixel; at closer and further depths, pixels will start
to get interpolated.
"""#:
grid = Shady.PixelRuler( w ).Set( z=0, carrierTranslation=w.size / 2 )
"""
Import OpenGL command bindings, from the third-party `PyOpenGL`
package. (NB: you could use `pyglet.gl` here instead of
`OpenGL.GL`, but pyglet does not wrap any of the gl* functions,
which means some of them are less easy-to-use: for example you
would have to wrap sequences by hand in `ctypes` containers---
it's easier just to install PyOpenGL).
"""#:
from OpenGL.GL import *
"""
Now we'll define a custom stimulus class. From Shady's point of
view the only requirement is that it should have a method called
`.Draw()` or `.draw()`, taking no arguments. There is no need
for the class to contain any reference to Shady classes or
functions---you can work entirely in OpenGL.
"""#.
""#:
class CustomStimulus( object ):
def __init__( self ):
self.vertexColors = {}
self.last_normal = None
self.boring = False
self.angular_velocities = [ 23, 31, 37 ]
self.rotation = Shady.Integral( lambda t: self.angular_velocities )
glEnable( GL_LIGHTING )
glEnable( GL_LIGHT0 )
def Normal( self, x, y, z ):
"""
Define a normal vector (in legacy-OpenGL "direct mode"),
and remember it (for the purpose of recalling which color
belongs to which face, when `.boring` is set to `True`).
"""
glNormal3f( x, y, z )
self.last_normal = ( x, y, z )
def Vertex( self, x, y, z ):
"""
Draw a vertex (in legacy-OpenGL "direct mode") and choose a
color for it if not already chosen.
"""
if self.boring: key = self.last_normal # different solid color on each face
else: key = ( x, y, z ) # different color at each vertex
color = self.vertexColors.get( key, None )
if color is None: color = self.vertexColors[ key ] = self.NewColor()
glColor4f( *color ) # only respected if lighting is disabled
glMaterialfv( GL_FRONT, GL_AMBIENT, color ) # only respected if lighting is enabled
glMaterialfv( GL_FRONT, GL_DIFFUSE, color ) # only respected if lighting is enabled
glVertex3f( x, y, z )
def NewColor( self ):
color = [ random.random() for channel in 'rgb']
if len( color ) < 4: color.append( 1.0 )
return color
def Draw( self ):
# In this demo we'll use the GL_PROJECTION and GL_MODELVIEW matrix
# stacks, which are part of OpenGL's "fixed function pipeline", which
# is a deprecated ("legacy") OpenGL feature.
# (1) Projection
glMatrixMode( GL_PROJECTION )
glPushMatrix()
# Get the matrix product of w.matrixWorldNormalizer
# and w.matrixWorldProjection (NB: requires numpy):
m = w.projectionMatrix
# In the orthographic case, the z clipping planes are at z=-1 and z=+1.
# So let's change the z-scaling to give ourselves some room (+/- 0.5*width):
if 0.99 < m[ 2, 2 ] < 1.01:
m[ 2, 2 ] = 2.0 / w.width
# Transfer the projection matrix:
glLoadMatrixf( list( m.T.flat ) )
# (2) Scene composition
glMatrixMode( GL_MODELVIEW )
glPushMatrix()
glLightfv( GL_LIGHT0, GL_POSITION, [ -1.0, +1.0, -1.0, 0.0 ] )
glLightfv( GL_LIGHT0, GL_AMBIENT, [ 0.5, 0.5, 0.5, 1.0 ] )
glLightfv( GL_LIGHT0, GL_DIFFUSE, [ 1.0, 1.0, 1.0, 1.0 ] )
# Foreign stimuli don't have dynamic properties (they have no features
# at all, beyond what we decide to define here in this class) but let's
# make a poor-man's version here, to allow z to be dynamic:
z = self.z
if callable( z ): z = z( w.t )
# fixed-function-pipeline transformations:
glTranslatef( 0, 0, z ) # translation to the desired z coordinate
omega, phi, kappa = self.rotation( w.t )
glRotatef( omega, 1, 0, 0 ) # rotation about x axis as a function of time
glRotatef( phi, 0, 1, 0 ) # rotation about y axis as a function of time
glRotatef( kappa, 0, 0, 1 ) # rotation about z axis as a function of time
r = 100 # half the length of one side of the cube, in pixels
N = self.Normal
V = self.Vertex
glBegin( GL_QUADS ) # this is "direct-mode" drawing (also a legacy feature)
N(-1, 0, 0); V(-r,-r,-r); V(-r,-r,+r); V(-r,+r,+r); V(-r,+r,-r) # left
N(+1, 0, 0); V(+r,+r,-r); V(+r,+r,+r); V(+r,-r,+r); V(+r,-r,-r) # right
N( 0,-1, 0); V(-r,-r,-r); V(+r,-r,-r); V(+r,-r,+r); V(-r,-r,+r) # bottom
N( 0,+1, 0); V(-r,+r,+r); V(+r,+r,+r); V(+r,+r,-r); V(-r,+r,-r) # top
N( 0, 0,-1); V(-r,+r,-r); V(-r,-r,-r); V(+r,-r,-r); V(+r,+r,-r) # near
N( 0, 0,+1); V(-r,+r,+r); V(-r,-r,+r); V(+r,-r,+r); V(+r,+r,+r) # far
glEnd()
glPopMatrix() # finished with GL_MODELVIEW matrix stack
glMatrixMode( GL_PROJECTION )
glPopMatrix() # finished with GL_PROJECTION matrix stack
"""
Now that it's defined, we'll add it to the World. We'll give it
the actual class object, rather than an instance of that class.
When the method `.AddForeignStimulus()` receives a callable object,
it calls it to obtain an instance. This is an easy way of ensuring
that all those OpenGL calls are "deferred" into the correct thread
where necessary:
"""#:
c = w.AddForeignStimulus( CustomStimulus, z=-100 )
"""
It doesn't look quite right, does it? Let's turn OpenGL's depth
test on, to ensure that the occluded parts of the surfaces are not
drawn:
"""#:
w.Culling( True )
""#>
@w.EventHandler( slot=-1 )
def Keys( self, event ):
if event >> "kp[p]": # toggle perspective/orthographic projection
self.perspectiveCameraAngle = 0 if self.perspectiveCameraAngle else 120
if event >> "kp[g]": # toggle pixel grid on/off (at z=0)
grid.visible = not grid.visible
if event >> "kp[z]": # toggle depth oscillation on/off
c.z = 0 if callable( c.z ) else Shady.Oscillator( 0.3 ) * 100
if event >> "kp[l]": # toggle lighting on/off
if glIsEnabled( GL_LIGHTING ): glDisable( GL_LIGHTING )
else: glEnable( GL_LIGHTING )
if event >> "kp[c]": # re-randomize colors (with shift to change mode)
c.vertexColors.clear()
if 'shift' in event.modifiers: c.boring = not c.boring
if event >> "kp[d]": # toggle depth test on/off
if glIsEnabled( GL_DEPTH_TEST ): w.Culling( False )
else: w.Culling( True )
if event >> "kp[ ] kp[s]": # pause/unpause spin
if all( x==0 for x in c.angular_velocities ):
c.angular_velocities = [ 23, 31, 37 ]
else:
c.angular_velocities = [ 0, 0, 0 ]
""#>
print("""
P toggle perspective/orthographic projection
G toggle pixel-grid on/off at z=0
Z toggle z-oscillation on/off (note how the cube
interacts with the pixel-grid Stimulus)
S pause/unpause the spin
L toggle lighting effect on/off
C re-randomize colors (press shift to switch
between two different coloring strategies)
D toggle depth test on/off
""")
""#>
Shady.AutoFinish( w ) # tidy up, in case we're not running this with `python -m Shady`