La quête du triangle rouge

La quête du triangle rouge

Introduction

Ça, c’est un triangle rouge.

void draw_triangle() {
    glBegin(GL_TRIANGLES);
        glColor3f(1.0f, 0.0f, 0.0f);
        glVertex2f( 0.0f,  0.5f);
        glVertex2f(-0.5f, -0.5f);
        glVertex2f( 0.5f, -0.5f);
    glEnd();
}

Enfin, c’était un triangle rouge. Aujourd’hui, les bibliothèques graphiques ont évolué. OpenGL s’est modernisé, et Vulkan est apparu, et, le moins que l’on puisse dire, c’est que la barrière à l’entrée est devenue immense pour tous les débutants et autodidactes.

Adieu le triangle en littéralement six lignes de code, maintenant place aux shaders et aux pipelines de rendu. En spécifiant tout nous-même et en configurant tout nous-même, nous perdons certes en productivité, mais le gain en compréhension est non négligeable.

OpenGL, même moderne, se fait vieux avec son modèle en machine à états. En dehors des configurations modestes, comme sur mobile avec OpenGL ES, le futur est désormais à Vulkan et aux technologies bas-niveaux.

Il existe un outil que l’on pourrait placer entre OpenGL et Vulkan. Plus moderne que le premier, moins verbeux que le second, laissez-moi vous présenter wgpu. Il s’agit d’une bibliothèque de rendu en Rust multi-plateforme, performante et sécurisée.

Dans cet article, je vous propose un voyage exigeant mais passionnant : comprendre comment fonctionnent les bases de wgpu. Nous commencerons par ouvrir une fenêtre avec winit, puis nous plongerons tête la première dans les rouages et la mécaniques de wgpu. J’ai structuré cet article en deux parties : la première explique la nature et les concepts que nous rencontrerons à l’implémentation. La seconde entre dans les détails du code.

Je ne vous promets rien d’autre que du sang et des larmes, et il est probable que cet article dévore l’intégralité de votre âme. Mais quel plaisir que de retrouver notre vieil ami le triangle rouge ! Bon courage, ça en vaut la peine.

Comprendre les grandes étapes

Dessiner un triangle rouge avec wgpu n’est pas simple : il y a beaucoup de concepts à comprendre avant d’obtenir le moindre résultat. Pour faciliter la compréhension, nous allons découper cette tâche en cinq grandes étapes, allant de l’ouverture d’une fenêtre vide au dessin du triangle rouge.

Ouvrir une fenêtre vide

Avant d’afficher quoi que ce soit, nous allons devoir ouvrir une fenêtre et la configurer. Pour cela, nous allons utiliser winit, une bibliothèque spécialisée de Rust.

Winit est une bibliothèque très populaire de manipulation de fenêtre. Elle est portable et permet de gérer les évènements comme le redimensionnement et les entrées / sorties via le clavier ou la souris.

Elle expose un trait ApplicationHandler comme point d’entrée permettant de réagir aux différents évènements de la fenêtre. Par exemple, la fonction resumed est appelée lorsque l’application apparaît, window_event lorsqu’un évènement survient (redimensionnement, dessin du contenu de la fenêtre, etc) ou encore exiting lors de la fermeture de la fenêtre.

Winit fonctionne autour d’une event loop. C’est une représentation de la boucle d’évènement qui se matérialise en deux structures : EventLoop qui correspond à la configuration initiale (ou encore au contexte), et ActiveEventLoop qui est utilisée quand l’application tourne.

La bibliothèque winit ne s’occupe que de la fenêtre, et pas de son contenu. Pour afficher quoique ce soit, nous passerons par la bibliothèque wgpu. L’interaction entre ces deux mondes se situe au niveau de la surface d’affichage. Cette surface, nous allons devoir la créer et la configurer pour faire communiquer winit et wgpu.

Configurer la surface d’affichage

La fenêtre ne s’ouvrira pas si nous n’affichons rien dedans, ce qui est, je trouve, une certaine source de confusion (mais pourquoi ma fenêtre n’apparaît pas, bon sang ?!). Et c’est là que nous plongeons en enfer. En effet, nous allons avoir besoin de pas mal de configurations avant d’avoir notre triangle rouge.

La première étape est de créer une instance de wgpu. Elle représente le point d’entrée de l’API et permet de construire deux structures importantes : la surface et l’adapter.

La surface correspond à une zone d’affichage rectangulaire sur laquelle nous allons pouvoir dessiner plus tard. L’adaptateur (adapter) représente notre carte graphique, et est utilisé pour configurer et choisir le GPU.

À partir de là, nous pouvons créer un device à partir de l’adaptateur. Les devices sont des périphériques logiques : c’est la matérialisation de la connexion ouverte vers un adapter. Le device est accompagné d’une file de commandes (queue) que l’on peut lui passer, ce que nous ferons pour afficher notre triangle (mais nous n’en sommes pas encore là).

Avec notre device et sa file de commandes, nous allons pouvoir parler au GPU dans la langue qu’il comprend : les shaders.

Écrire les shaders nécessaires

Un shader est un programme qui s’exécute sur le GPU. Nous avons besoin de deux types de shaders : le vertex shader qui détermine la position des sommets (vertex en anglais), et le fragment shader qui associe une couleur aux pixels. Ces shaders sont réunis et compilés dans une structure dédiée : le ShaderModule.

Pour communiquer avec le GPU, il est nécessaire de spécifier un pipeline de rendu. C’est une structure immuable dont le rôle est de définir comment les informations doivent être interprétées. Cela inclut les shaders bien sûr, mais aussi la topologie du rendu : la liste des sommets correspond à des triangles ? Des points ? Comment les triangles sont calculés ? Tous les trois sommets, ou en strip (on reprend les deux derniers sommets pour former le prochain triangle) ?

Le pipeline de rendu ne fait que décrire une configuration au GPU. Pour afficher notre triangle, nous avons besoin de transmettre des données réelles au sujet de notre triangle. Il s’agit de la position de ses sommets, de leurs couleurs, etc. Ces informations indispensables, le GPU va les chercher dans des tableaux en mémoire : ce sont les buffers.

Remplir les buffers

À ce stade, nous avons une description du pipeline de rendu, reste à spécifier les données réelles qui seront disponibles pour le GPU.

Ces données sont rangées dans des tableaux (les buffers) et peuvent être interprétées différemment selon la nature du buffer.

Le vertex buffer contient les sommets qui seront envoyés au vertex shader. On parle ici de tableaux dont les éléments représentent des coordonnées de sommets, leurs couleurs et d’autres attributs.

L’index buffer est optionnel et constitue une optimisation sur le nombre de sommets dans le vertex buffer. En effet, l’index buffer contient des indices de sommets présents dans le vertex buffer. Comme le GPU ne fonctionne qu’avec des triangles, on peut se retrouver avec des sommets qui font doublons dans des formes comme un rectangle en réalité composé de deux triangles. Les sommets en commun seraient redondants. Plutôt que de les doubler dans le vertex buffer, on l’utilise deux fois dans l’index buffer.

Pour récapituler, nous avons un pipeline de rendu qui représente la chaîne de production permettant de partir de sommets et de les traiter dans le GPU grâce aux shaders.

Nous avons également des données prêtes pour l’affichage, rangées dans des tableaux internes aux GPU (le vertex buffer et l’index buffer).

Maintenant, nous allons faire le lien entre ces données descriptives, et l’affichage sur vos écrans.

Dessiner à l’écran

La première chose à faire pour afficher sur une fenêtre winit, c’est de considérer sa surface (celle que nous avons définie plus tôt) et d’en extraire sa texture.

La différence entre une texture et une surface est fondamentale : une surface est une interface entre l’application et la fenêtre, tandis que la texture est un objet purement GPU.

Pour déterminer où sur la texture l’affichage doit avoir précisément lieu, nous construisons une vue (TextureView).

Pour dessiner sur une portion de l’écran (sur notre vue donc), nous utilisons notre device (la connexion) pour créer un encodeur (encoder). Son rôle est de construire une liste de commandes pour le GPU.

L’encoder peut alors instancier une passe de rendu (render pass) paramétrée par le pipeline de rendu et par les buffers définis plus tôt. C’est cet objet, la passe de rendu, qui alimentera par le truchement de l’encoder la file de commandes (queue).

En bref

Voici un récapitulatif des objets manipulés tout au long du processus d’affichage de notre triangle rouge (que l’on ne voit toujours pas), suivi d’un petit schéma présentant les dépendances entre ces objets.

ObjetsRôles
InstanceAccès à l’API de wgpu.
SurfaceZone de l’écran sur laquelle on peut dessiner.
AdapterMatériel graphique physique ou logique.
DeviceConnexion ouverte avec le GPU.
QueueFile des commandes à transmettre au GPU.
Shader ModuleModule contenant le vertex shader et optionnellement le fragment shader
Render PipelineDescription des données pour le GPU.
Vertex BufferTableau contenant les points (positions, couleurs, etc).
Index BufferTableau contenant les indices des points.
TextureObjet du GPU composé de pixels.
Texture ViewVue sur une portion de la texture.
EncoderProduit le CommandBuffer transmis au GPU pour effectuer le rendu.
Render PassDescription d’une passe caractérisée par son pipeline et ses buffers.

Pour afficher le triangle rouge, on utilise la render pass sur la view que l’on paramètre avec le pipeline, les shaders et les buffers.

flowchart TD
instance --> adapter
instance --> surface
surface --> texture
texture --> view
adapter --> device
adapter --> queue
device --> pipeline
device --> shader_module
device --> buffers
device --> encoder
encoder --> render_pass

Plongeons au cœur de wgpu

Dans cette seconde partie, nous entrerons dans le concret, dans le code. Nous reviendrons sur chaque étape de la première partie et, petit-à-petit, nous réaliserons le hello world de wgpu : le saint triangle rouge.

Nous adopterons une architecture simple, principalement composée d’un état graphique wgpu (State) et d’un point d’entrée pour l’application winit (App).

classDiagram

class State {
    - surface
    - config
    - device
    - queue
    - is_surface_configured
    - render_pipeline
    - window
    - vertex_buffer
    - index_buffer
}

class App {
  - state
}

Nous commencerons par le plus simple : la structure App et la gestion des évènements. Ensuite, nous attaquerons State, ce qui peut paraitre intimidant. En fait, c’est intimidant : c’est un gros morceau, il faut s’accrocher et ne pas hésiter à relire la première partie pour comprendre ce que l’on cherche à faire précisément.

Dépendances

Avant de commencer le code, voici le Cargo.toml du projet.

[package]
name = "quete_triangle"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.102"
bytemuck = "1.25.0"
env_logger = "0.11.10"
log = "0.4.29"
pollster = "0.4.0"
wgpu = "29.0.1"
winit = "0.30.13"

Nous avons un certain nombre de dépendances :

  • anyhow permet de gérer facilement les erreurs
  • bytemuck nous sera utile pour transformer nos sommets en tableau de bytes
  • log et env_logger permettent de logger les évènements de l’application
  • pollster est utile pour utiliser du code asynchrone dans un contexte synchrone
  • wgpu est notre bibliothèque de rendu
  • winit gère la fenêtre et ses évènements

Afficher une fenêtre avec winit

Winit est la bibliothèque nous permettant de gérer le rendu et les évènements de la fenêtre de l’application.

pub struct App {

}

impl App {
    pub fn new() -> Self {
	Self {
	}
  }
}

Pour gérer les évènements de la fenêtre, winit nous offre un trait Rust que notre structure App devra implémenter : ApplicationHandler.

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
	 // ... ici on initialize la fenêtre
    }

    fn window_event(
        &mut self,
        event_loop: &winit::event_loop::ActiveEventLoop,
        _window_id: winit::window::WindowId,
        event: winit::event::WindowEvent,
    ) {
      // ... ici on gère les évènements
  }
}

La fonction resumed est appelée lorsque la fenêtre apparait. Chaque évènement de la fenêtre est délégué à la fonction window_event.

resumed est simple : on crée une fenêtre winit nommée window grâce à l’event loop

fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
	let attributes = Window::default_attributes();
	let window = Arc::new(event_loop.create_window(attributes).unwrap());
	// ... ici on liera la fenêtre window au State.
}

window_event contient beaucoup de choses, mais rien de très compliqué. On se contente de faire le lien entre le comportement de la fenêtre (être redimensionnée par exemple) avec notre état State.

fn window_event(
        &mut self,
        event_loop: &winit::event_loop::ActiveEventLoop,
        _window_id: winit::window::WindowId,
        event: winit::event::WindowEvent,
    ) {
	let state = match &mut self.state {
	    Some(c) => c,
	    None => return
	};

	match event {
	    WindowEvent::CloseRequested => {
		  event_loop.exit();
	    },

	    WindowEvent::Resized(size) => {
		  // ... redimensionner le State ici
	    },

	    WindowEvent::RedrawRequested => {
		  // ... dessiner le State ici
	    }

	    WindowEvent::KeyboardInput { event: KeyEvent {
		physical_key: PhysicalKey::Code(code),
		state: key_state,
		..
	    }, .. } => match (code, key_state.is_pressed()) {
		(KeyCode::Escape, true) => {
		    event_loop.exit();
		},
		_ => {}
	    },

	    _ => {
	    }
	}
    }

Le code de App est incomplet à ce stade, et ce tant que nous n’avons pas la structure State de finalisée. Nous reviendrons complèter App au moment opportun.

Initialisation de l’état

Bon, nous entrons dans le fonctionnement de la structure State. Elle représente l’état du processus graphique. Nous commencerons par créer l’instance wgpu, l’objet qui expose l’API. Attention, c’est verbeux.

let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
	    // Choix du backend graphique
	    // PRIMARY = Vulkan, Metal ou Dx12
	    // SECONDARY = OpenGL, OpenGL ES
	    backends: wgpu::Backends::PRIMARY,
	    
	    // Acces aux options du backend pour, par exemple, forcer
	    // la version d'opengl.
	    backend_options: Default::default(),
	    
	    // Comportement de l'instance (surtout pour la validation
	    // et le debogage)
	    flags: Default::default(),

	    // Décide a partir de quand informer l'hardware concernant
	    // les contraintes systèmes.
	    memory_budget_thresholds: Default::default(),

	    // Integration a un systeme de fenetrage externe comme GTK
	    // ou Qt.
	    display: None
	});

L’instance nous offres la surface et l’adapter. Rappellons que la surface est l’interface entre la fenêtre et le rendu wgpu, et que l’adapter représente le matériel graphique physique ou virtuel.

	let surface = instance.create_surface(window.clone()).unwrap();

	let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
	    power_preference: wgpu::PowerPreference::None,
	    // On cherche un adapter compatible
	    // avec notre surface
	    compatible_surface: Some(&surface),
	    // Force wgpu à choisir un adapter
	    // qui marche sur tous les hardware
	    force_fallback_adapter: false
	}).await?;

Maintenant, connectons nous à l’adapter via un device. On obtient également une queue que nous utiliserons pour communiquer avec le GPU.

let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor {
	    // Identifiant optionnel pour le debogage
	    label: None,
	    // Features additionnelles (RAY_TRACING par exemple)
	    required_features: wgpu::Features::empty(),
	    // Features experimentales potentiellement buggees
	    experimental_features: wgpu::ExperimentalFeatures::disabled(),
	    // Definit les contraintes hardwares max comme la taille
	    // des textures ou le nombre maximum de vertex buffers.
	    required_limits: wgpu::Limits::default(),
	    // Indique à wgpu comment optimiser l'usage de la
	    // memoire. Cela concerne la quantite de memoire reservee
	    // par exemple.
	    memory_hints: Default::default(),
	    // Active les traces pour le debogage.
	    trace: wgpu::Trace::Off
	}).await?;

Nous allons avoir besoin de configurer la surface pour pouvoir écrire dessus. Nous commençons par déterminer le format de la surface avant de créer une SurfaceConfiguration.

	let surface_capabilities = surface.get_capabilities(&adapter);

	// Choix du format des pixels.
	// sRGB est l'espace de couleur standard pour les ecrans.
	let surface_format = surface_capabilities.formats.iter()
	    .find(|k| k.is_srgb())
	    .copied()
	    .unwrap_or(surface_capabilities.formats[0]);

	let size = window.inner_size();

	// Fait le pont entre le code GPU et la fenetre de l'OS.
	let config = wgpu::SurfaceConfiguration {
	    // Definit comment la texture sera utilisee.
	    // RENDER_ATTACHEMENT correspond a une utilisation comme
	    // cible de rendu.
	    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
	    // Le format des pixels de la texture.
	    format: surface_format,
	    // Dimension de la texture
	    width: size.width,
	    height: size.height,
	    // Controle la synchronisation verticale et la latence.
	    // present_modes[0] correspond au premier mode supporte
	    // par le driver.
	    present_mode: surface_capabilities.present_modes[0],
	    // Gere la transparence de la fenetre.
	    alpha_mode: surface_capabilities.alpha_modes[0],
	    // Utile pour creer des vues d'une texture dans un autre
	    // format de pixels.
	    view_formats: vec![],
	    // Nombre maximal de frames pouvant etre mises en file
	    // d'attente.
	    desired_maximum_frame_latency: 2
	};

L’étape suivante pour l’initialisation : le pipeline de rendu. Il a besoin des shaders que nous écrirons dans la prochaine section, ainsi que d’un pipeline layout.

    // Shader est ici un ShaderModule : il peut contenir plusieurs
	// shaders (vertex, fragment, etc).
	let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
	    label: Some("Shader"),
	    source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into())
	});

	// Le pipeline layout correspond au plan des ressources globales comme
	// les uniforms et les textures.
	let render_pipeline_layout =
	    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
		label: Some("Render Pipeline Layout"),
		bind_group_layouts: &[],
		// Petite zone en memoire rapide pour echanger des
		// donnees avec le GPU. Ici on ne l'utilise pas.
		immediate_size: 0,
	    });

Et maintenant le pipeline lui-même. Oui c’est l’enfer. J’ai largement commenté histoire de bien tout comprendre. Dans les grandes lignes on spécifies surtout le vertex shader ainsi que le fragment shader et la topologie des primitives. Le reste ce sont des valeurs par défauts que nous n’utiliserons pas (par exemple pour l’anti-aliasing).

// La render pipeline indique au GPU comment interpreter les
	// donnees.
	let render_pipeline =
	    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
		label: Some("Render Pipeline"),
		layout: Some(&render_pipeline_layout),

		vertex: wgpu::VertexState {
		    module: &shader,
		    entry_point: Some("vs_main"),
		    buffers: &[
			Vertex::desc()
		    ],
		    compilation_options: wgpu::PipelineCompilationOptions::default(),		    
		},

		fragment: Some(wgpu::FragmentState {
		    module: &shader,
		    entry_point: Some("fs_main"),
		    // La liste des targets accessible dans les
		    // shaders avec @location.
		    targets: &[
			Some(wgpu::ColorTargetState {
			    format: config.format,
			    // Ici on indique que l'on dessine par
			    // dessus les anciens pixels.
			    blend: Some(wgpu::BlendState::REPLACE),
			    // On ecrit bien R, G, B et Alpha.
			    write_mask:wgpu::ColorWrites::ALL
			})
		    ],
		    compilation_options: wgpu::PipelineCompilationOptions::default()
		}),

		primitive: wgpu::PrimitiveState {
		    topology: wgpu::PrimitiveTopology::TriangleList,
		    // Concerne les TriangleStrip (reutilisation des
		    // deux derniers sommets). Or, ici nous avons des
		    // TriangleList.
		    strip_index_format: None,
		    
		    // Le culling est une optimisation pour ne pas
		    // calculer les triangles qui tournent le dos à la
		    // camera.
		    front_face: wgpu::FrontFace::Ccw,
		    cull_mode: Some(wgpu::Face::Back),
		    polygon_mode: wgpu::PolygonMode::Fill,
		    
		    // Desactive le Z-clipping (suppression des objets
		    // lointains sur l'axe Z).
		    unclipped_depth: false,

		    // Par defaut un pixel est visible si le centre du
		    // pixel est recouvert par un triangle. En
		    // activant conservative, nous faisons en sorte
		    // que le pixel s'active quand n'importe quelle
		    // partie du triangle touche n'importe quelle
		    // partie du pixel. C'est surtout utile dans les
		    // calcules par GPU en physique par exemple.
		    conservative: false,
		},

		// On ne gere pas la profondeur.
		depth_stencil: None,

		// Les pixels peuvent etre scindes en
		// echantillons. L'anti-aliasing ne regarde pas
		// uniquement le centre des pixels, mais les
		// echantillons individuellement, apres quoi il
		// calcule la couleur du pixel en faisant la
		// moyenne. Pour count = 4, le GPU considere 4
		// echantillons par pixel.
		multisample: wgpu::MultisampleState {
		    // On echantillonne chaque pixel une seule fois.
		    count: 1,

		    // Chaque bit du mask control un
		    // echantillon. C'est utile pour faire de l'alpha
		    // to coverage.
		    mask: !0, // !0 correspond a 1111111....

		    // L'alpha to coverage est une technique avancee
		    // pour gerer la transparence plus efficacement en
		    // manipulant le mask.
		    alpha_to_coverage_enabled: false
		},

		// Technique pour dessiner une scene plusieurs fois
		// sous des angles differents (utile pour la VR par
		// exemple).
		multiview_mask: None,

		// Permet de donner au GPU un PipelineCache que l'on
		// peut recharger tres rapidement. Sans cache, le GPU
		// compile le shader a chaque demarrage.
		cache: None
	    });

On se retrouve là à la fin de l’initialisation. On définit les buffers et c’est fini pour cette partie du State.

    // On utilise ici bytemuck pour obtenir un &[u8]
	let vertex_buffer = device.create_buffer_init(
	    &wgpu::util::BufferInitDescriptor {
		label: Some("Vertex Buffer"),
		contents: bytemuck::cast_slice(vertex::VERTICES),
		usage: wgpu::BufferUsages::VERTEX
	    }
	);

	let index_buffer = device.create_buffer_init(
	    &wgpu::util::BufferInitDescriptor {
		label: Some("Index Buffer"),
		contents: bytemuck::cast_slice(vertex::INDICES),
		usage: wgpu::BufferUsages::INDEX
	    }
	);

Le State est correctement créé, il faut à présent définir les fonctions dont l’App a besoin pour fonctionner (les fonctions laissées vides dans la structure App).

Il s’agit des opérations de redimensionnement de la fenêtre et du rendu graphique.

Gestion du redimensionnement

Commençons par le plus simple : quand la fenêtre est redimensionnée, nous mettons à jour notre configuration de la surface et nous la reconfigurons pour la mettre à jour avec la nouvelle dimension de la fenêtre.

pub fn resize(&mut self, w: u32, h: u32) {
	if w > 0 && h > 0 {
	    self.config.width = w;
	    self.config.height = h;

	    // Fait des verifications de compatibilites, creer des
	    // ressources internes, et prepare le rendu en creant la
	    // Swap Chain.
	    self.surface.configure(&self.device, &self.config);
	    
	    self.is_surface_configured = true;
	}
}

Définissions les sommets de notre triangle dans un fichier dédié, vertex.rs.

// Le GPU ne comprend pas les structs rust. Il faut un moyen de
// convertir ces structures et tableaux d'octets : c'est le role de
// bytemuck.

// Bytemuck offre deux traits.

// 1. Pod (Plain Old Data) qui indique que la
// structure ne contient que des donnees simples (pas de Vec ni de
// String par exemple).

// 2. Zeroable indiquant qu'il est sur de remplir la structure avec
// des zeros.

// #[repr(C)] permet de garder l'ordre des champs comme en C.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
    position: [f32; 3],
    color: [f32; 3]
}

pub const VERTICES: &[Vertex] = &[
    Vertex { position: [0.0, 0.5, 0.0], color: [1.0, 0.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0], color: [0.0, 1.0, 0.0] },
    Vertex { position: [0.5, -0.5, 0.0], color: [0.0, 0.0, 1.0] },
];

pub const INDICES: &[u16] = &[
    0, 1, 2
];

impl Vertex {
    pub fn desc() -> wgpu::VertexBufferLayout<'static> {	
	wgpu::VertexBufferLayout {
	    array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
	    step_mode: wgpu::VertexStepMode::Vertex,
	    attributes: &[
		wgpu::VertexAttribute {
		    offset: 0,
		    shader_location: 0,
		    format: wgpu::VertexFormat::Float32x3,
		},

		wgpu::VertexAttribute {
		    offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
		    shader_location: 1,
		    format: wgpu::VertexFormat::Float32x3
		}
	    ]
	}
    }
}

Bon, nous voila au coeur du programme : le rendu. Voici les étapes à suivres : récupérer une vue à partir de notre surface, construire l’encoder responsable de générer les commandes envoyées au GPU, créer une passe de rendu, y attacher nos sommets et leurs indexes, et enfin soumettre les commandes à la file de commande de wgpu.

pub fn render(&mut self) -> anyhow::Result<()> {
	self.window.request_redraw();
	
	if !self.is_surface_configured {
	    return Ok(());
	}

	// Recupereration de la texture de l'ecran. La texture est un
	// objet purement GPU alors qu'une surface est une connexion
	// entre l'app et la fenetre.
	let output = match self.surface.get_current_texture() {

	    // Cas de succes : on accede a la texture.
	    wgpu::CurrentSurfaceTexture::Success(st) => st,

	    // Bonne texture mais mauvaise configuration
	    wgpu::CurrentSurfaceTexture::Suboptimal(st) => {
		self.surface.configure(&self.device, &self.config);
		st
	    },

	    // Pas terrible, on ignore et on reessaie plus tard.
	    // a/ Pas recu a temps
	    wgpu::CurrentSurfaceTexture::Timeout
	    // b/ La Fenetre est cachee.
		| wgpu::CurrentSurfaceTexture::Occluded 
	    // c/ La configuration n'est pas valide.
		| wgpu::CurrentSurfaceTexture::Validation => { return Ok(()); },

	    // Il faut tout reconfigurer, plus rien n'est compatible.
	    wgpu::CurrentSurfaceTexture::Outdated => {
		self.surface.configure(&self.device, &self.config);
		return Ok(());
	    }

	    // Cas critique
	    wgpu::CurrentSurfaceTexture::Lost => {
		anyhow::bail!("Lost device");
	    }
	};

	// La vue determine quelle portion de la texture exposer.
	let view: TextureView = output.texture.create_view(
	    // Ici pas de sous-region
	    &wgpu::TextureViewDescriptor::default() 
	);

	// L'encoder sert a construire des listes de commandes pour le
	// GPU. On donne des commandes, puis on le compile en
	// CommandBuffer.
	let mut encoder = self.device.create_command_encoder(
	    &wgpu::wgt::CommandEncoderDescriptor {
		label: Some("Render Encoder")
	    });

	{ // <--+
	    //  |
	    // Le render pass doit etre detruit avant la fin de la
	    // construction de l'encoder.
	    let mut render_pass = encoder.begin_render_pass(
		&wgpu::RenderPassDescriptor {
		    label: Some("Render Pass"),
		    // Definit ou dessiner (texture cible) et comment.
		    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
			// La vue ou dessiner.
			view: &view,
			// Pour l'anti-aliasing.
			resolve_target: None,
			// Quelles couches utiliser pour les textures
			// 3D.
			depth_slice: None,
			
			ops: wgpu::Operations {
			    // Load = que faire AVANT de dessiner
			    load: wgpu::LoadOp::Clear(wgpu::Color {
				r: 0.1,
				g: 0.2,
				b: 0.3,
				a: 1.0
			    }),

			    // Store = que faire du resultat APRES
			    // avoir dessiner (Store ou Discard).
			    store: wgpu::StoreOp::Store
			}})],

		    // Test de profondeur pour la 3D.
		    depth_stencil_attachment: None,

		    // Sans occlusion culling le GPU dessine tout ce
		    // qu'on lui envoies. L'occlusion culling permet
		    // de tester la visibilite des pixels a des fins
		    // d'optimisations. occlusion_query_set permet
		    // d'acceder aux informations permettant de mettre
		    // en place l'occlusion culling.
		    occlusion_query_set: None,

		    // Permet de mesurer les performances GPU.		    
		    timestamp_writes: None,

		    // Pour le rendu VR (multi oeil)
		    multiview_mask: None
		}
	    );
	    
	    render_pass.set_pipeline(&self.render_pipeline);

	    // Definit le vertex buffer au slot 0 en entier (slice(..)).
	    render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
	    render_pass.set_index_buffer(
		self.index_buffer.slice(..),
		wgpu::IndexFormat::Uint16
	    );
	    
	    // range des vertices
	    //                | range des instances (0..1 si non utilise)
	    //                |      |
	    //                |      |
	    //                v      v
	    //render_pass.draw(0..(vertex::VERTICES.len() as u32), 0..1);

	    //                                                      base vertex
	    //                                                          |
	    //                                                          v
	    render_pass.draw_indexed(0..(vertex::INDICES.len() as u32), 0, 0..1);
	}
	
	// Envoie le CommandBuffer au GPU pour execution.
	self.queue.submit(
	    // Transforme l'encodeur en CommandBuffer a usage unique.
	    //                     |
	    //                     v
	    std::iter::once(encoder.finish())

	);
	
	// Indique que le buffer est pret !
	output.present();
	
	Ok(())
    }

Complétons dès maintenant notre structure App en utilisant les méthodes de State.

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
	let attributes = Window::default_attributes();
	let window = Arc::new(event_loop.create_window(attributes).unwrap());
	self.state = Some(pollster::block_on(State::new(window)).unwrap());
    }

    fn window_event(
        &mut self,
        event_loop: &winit::event_loop::ActiveEventLoop,
        _window_id: winit::window::WindowId,
        event: winit::event::WindowEvent,
    ) {
	let state = match &mut self.state {
	    Some(c) => c,
	    None => return
	};

	match event {
	    WindowEvent::CloseRequested => {
		event_loop.exit();
	    },

	    WindowEvent::Resized(size) => {
		state.resize(size.width, size.height); // NOUVEAU : on utilise resize de State
	    },

	    WindowEvent::RedrawRequested => {
		state.render().unwrap(); // NOUVEAU : on rend le tout avec State
	    }

	    WindowEvent::KeyboardInput { event: KeyEvent {
		physical_key: PhysicalKey::Code(code),
		state: key_state,
		..
	    }, .. } => match (code, key_state.is_pressed()) {
		(KeyCode::Escape, true) => {
		    event_loop.exit();
		},
		_ => {}
	    },

	    _ => {
	    }
	}
    }
}

Dans notre code du rendu de l’état State, nous faisons référence à des shaders que nous n’avons pas encore spécifiés.

Spécifier les shaders en WGSL

Les shaders sont écrit dans un langage appelé WGSL (pour WebGPU Shader Language). Voici nos deux shaders, dans un seul fichier shader.wgsl.

// Vertex shader

// @location(n) permet de numeroter les slots qui
// transitent entre les etapes de la pipeline.

// @builtin(name) sont les variables magiques du GPU.
// Exemple 1 : @builtin(position) correspond aux coordonnees
// de sortie du vertex.
// Exemple 2 : @builtin(vertex_index) correspond a l'indice du sommet actuel.

struct VertexInput {
  @location(0) position: vec3<f32>,
  @location(1) color: vec3<f32>,
}

struct VertexOutput {
  @builtin(position) clip_position: vec4<f32>,
  @location(0) color: vec3<f32>
};

// Dans VertexdOutput, @builtin(position) marque la variable qui le
// precede afin d'indiquer au GPU ce qu'il doit en faire.

// Le vertex shader peut prendre en parametres des attributs
// personnalises avec @location et des variables systeme avec
// @builtin. Il doit retourner une position avec l'attribut
// @builtin(position).

// @builtin en parametre permet de recuperer une valeur aupres du
// GPU. @builtin en retour de fonction permet d'indiquer au GPU son
// usage.

@vertex
fn vs_main(
   model: VertexInput
) -> VertexOutput {

  var out: VertexOutput;
  out.color = model.color;
  out.clip_position = vec4<f32>(model.position, 1.0);
  return out;
}

// Fragment shader


// Le fragment shader peut recevoir en parametres tout ce qui a ete mis
// dans la structure de sortie du vertex shader ainsi que des variables
// systeme. Il doit retourner une couleur pour la cible de rendu 0
// marquee avec @location(0).

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
  return vec4<f32>(in.color, 1.0);
}

Enfin, voici le fichier principal chargé de créer l’event loop et de lancer l’application.

use app::App;
use winit::event_loop::EventLoop;

mod state;
mod app;
mod vertex;

fn main() -> anyhow::Result<()> {
    env_logger::init();
    
    let event_loop = EventLoop::with_user_event().build()?;
    let mut app = App::new();
    event_loop.run_app(&mut app)?;
    
    Ok(())
}

Conclusion

Brique par brique, nous avons posé les fondations d’une application graphique wgpu. Le processus est verbeux, mais il s’en dégage un schéma loin d’être infranchissable. Déjà créer un pipeline de rendu à partir de nos shaders, puis dessiner une passe de rendu avec nos buffers.

flowchart LR
pipeline
render_pass(Render pass)
vertex([Vertex shader])
fragment([Fragment shader])

vertex_buffer([Vertex buffer])
index_buffer([Index buffer])


vertex --> pipeline
fragment --> pipeline



vertex_buffer --> render_pass
index_buffer --> render_pass
pipeline --> render_pass

render_pass --> draw

Cette base de code n’est pas une fin, mais un commencement. À partir de là, nous pouvons construire des applications 2D et 3D. Il reste tant à explorer. La suite logique, et peut-être les sujets d’articles prochains, ce sont les textures et la caméra.

Liens et ressources

  • Vous trouverez le code source complet du triangle rouge sur github.
  • La meilleure ressource pour apprendre wgpu est sans doute Learn Wgpu. Le document est très pédagogique et propose de nombreux tutoriels. Ce fût ma source principale d’informations lors de la rédaction de cet article.
  • Et enfin, difficile de passer à côté de la documentation officielle de wgpu et de winit.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *