Je voulais juste une petite appli « rafraichissante » et calme où il suffirait de glisser son doigt sur le smartphone pour faire des petites vagues et éclaboussures d’eau.
Ensuite, j’ai voulu y recréer l’ambiance d’une des plages où j’aime à me détendre l’été. De fil en aiguille, ça devient un jeu presque complet avec des petites balles flottantes à déplacer pour un atteindre un rocher en forme de C (ou fer à cheval), avec poissons gobeurs de balles, des courants contraires, des méduses, … et d’autres choses à venir !
Testez par vous-même : https://ondaclara.antweb.fr
Si vous êtes intéressé par la conception du jeu, je vous présente ci-après les règles physiques qui y sont mises en œuvre, ainsi que le code Python qui les illustre.
La physique d’Onda Clara
Ce qu’un jeu simule vraiment — et ce qu’il approxime.
Anthony Henriques — Antweb.fr
Avertissement méthodologique
Un jeu n’est pas une simulation. Il emprunte à la physique ce qui sert la sensation, et s’en écarte partout où l’exactitude coûterait sans se voir. Ce document distingue systématiquement :
- ✅ Ce qui est physiquement exact — l’équation est celle des manuels ;
- ⚠️ Ce qui est une approximation assumée — physiquement faux, mais visuellement juste ;
- 🐛 Ce qui est une erreur — physiquement faux, et invisible seulement par accident.
Cette dernière catégorie est la plus instructive. Elle existe dans ce jeu.
1. L’équation des ondes
La loi
La surface de l’eau obéit à l’équation des ondes en deux dimensions :
où est la hauteur de la surface et la célérité de l’onde.
C’est la même équation que pour une corde vibrante, une membrane de tambour, ou le champ électromagnétique dans le vide. Sa signature : l’accélération d’un point est proportionnelle à sa courbure locale. Un creux entouré de crêtes remonte ; une crête isolée s’affaisse.
La discrétisation
On échantillonne sur une grille et dans le temps par pas . En remplaçant les dérivées secondes par des différences finies centrées :
En posant le nombre de Courant , on isole l’instant suivant :
C’est un schéma dit leapfrog (saute-mouton) : il utilise deux instants passés pour produire le suivant. D’où les deux tampons du code, buffer1 et buffer2.
Le code du jeu
buffer2[idx] = ((L + R + U + D) * 0.5 - buffer2[idx]) * damp;
Où sont passés les ? Posons :
Les termes en s’annulent exactement. Le facteur 0.5 du code est .
La condition de stabilité (CFL)
Un schéma explicite ne peut pas laisser l’information numérique voyager moins vite que l’onde physique. C’est la condition de Courant–Friedrichs–Lewy. En dimension :
En 2D : .
Or notre schéma a
Le jeu tourne exactement à la limite CFL. C’est le réglage optimal : célérité maximale, stabilité tout juste préservée. Le facteur d’amortissement damp = 0.982 fournit la marge de sécurité qui absorbe l’accumulation d’erreurs d’arrondi.
Au-delà de cette limite, l’amplitude croît exponentiellement : la simulation « explose » en quelques dizaines de pas.
L’amortissement
Ce n’est pas de la viscosité au sens de Navier–Stokes. C’est un amortissement uniforme et artificiel, qui retire la même fraction d’énergie partout à chaque pas. ⚠️ Une viscosité réelle amortit préférentiellement les courtes longueurs d’onde (elle dépend de ). Notre constant amortit tout également.
L’énergie décroît alors géométriquement : , soit une demi-vie de
2. La force exercée sur les balles
La loi
Une bille flottant sur une surface inclinée subit la composante tangentielle de son poids. Pour une pente faible :
et donc, par la deuxième loi de Newton :
La masse disparaît. C’est le principe d’équivalence : tous les corps subissent la même accélération dans un champ de pesanteur, quelle que soit leur masse. Galilée, tour de Pise.
Le code du jeu
p.vx -= (buffer1[idx + 1] - buffer1[idx - 1]) * p.mass * g;
Le gradient est calculé par différence centrée — cela, c’est correct :
(le facteur est absorbé dans la constante g, ici waveForceGain = 0.005).
Mais le code multiplie par la masse. 🐛
Une balle deux fois plus lourde accélérerait deux fois plus vite. C’est l’inverse de la physique.
Pourquoi ce bug est invisible
Toutes les balles du jeu ont mass = 0.8. Le produit 0.8 × 0.005 = 0.004 n’est qu’un facteur constant, absorbé dans la calibration. Le bug est latent.
Il se réveillerait immédiatement si l’on donnait des masses différentes aux balles — une idée envisagée pour un futur niveau. Une balle « lourde » deviendrait alors plus réactive, ce qui heurterait l’intuition de tout joueur.
C’est un cas d’école : un paramètre maintenu constant peut masquer une erreur de modèle pendant des années.
Correction : supprimer * p.mass, puis recalibrer waveForceGain d’un facteur .
3. Les collisions entre balles
La loi
Deux disques élastiques qui se heurtent échangent une impulsion dirigée selon la normale au contact. Soit le vecteur unitaire joignant les centres, et la vitesse relative projetée sur cette normale :
L’impulsion scalaire, pour un coefficient de restitution :
puis :
Le dénominateur est l’inverse de la masse réduite .
Le code du jeu
let velAlongNormal = (p2.vx - p1.vx) * nx + (p2.vy - p1.vy) * ny;
if (velAlongNormal < 0) {
let impulse = -(1 + 0.9) * velAlongNormal / (1/p1.mass + 1/p2.mass);
p1.vx -= (impulse * nx) / p1.mass;
p2.vx += (impulse * nx) / p2.mass;
}
✅ Exact. C’est la formule des manuels, avec (collision quasi élastique).
Deux subtilités méritent l’attention :
Le test velAlongNormal < 0. Il n’applique l’impulsion que si les balles se rapprochent. Sans lui, deux balles qui se séparent encore en état de chevauchement recevraient une impulsion supplémentaire, et l’énergie croîtrait sans borne.
La séparation positionnelle. Le code écarte d’abord les disques de moitié du recouvrement chacun :
p1.x -= nx * (overlap / 2);
p2.x += nx * (overlap / 2);
C’est une projection de contrainte, pas de la dynamique. Elle corrige l’erreur d’intégration due au pas de temps fini. Sans elle, deux balles peuvent rester imbriquées et « vibrer ».
Ce qui est conservé
La quantité de mouvement est exactement conservée :
L’énergie cinétique, elle, décroît d’un facteur sur la composante normale : c’est le sens physique du coefficient de restitution.
4. Réflexion sur les obstacles : la condition de Neumann
La loi
Une paroi rigide impose que le flux normal soit nul :
C’est une condition aux limites de Neumann homogène. Elle produit une réflexion totale, en phase.
Sa duale, la condition de Dirichlet ( à la paroi), produit une réflexion en opposition de phase — c’est ce qui se passe au bout libre d’une corde.
Le piège rencontré
La version initiale du jeu écrivait naïvement :
if (obstacleMask[idx] === 1) { buffer2[idx] = 0; continue; }
C’est-à-dire dans le rocher. Mais une cellule d’eau voisine calcule sa valeur en moyennant ses quatre voisins — dont cette cellule à zéro. Le rocher aspirait l’onde au lieu de la réfléchir : un mur absorbant.
La solution correcte
C’est la cellule d’eau qui doit substituer sa propre valeur à celle du rocher :
const c = buffer1[idx];
const L = obstacleMask[idx - 1] ? c : buffer1[idx - 1];
Ainsi , donc : gradient normal nul, réflexion parfaite.
Mesure sur le jeu : l’énergie contenue à l’intérieur du rocher en fer à cheval passe de 13,5 % à 22,9 % de celle de l’eau libre.
5. La houle transporte-t-elle ?
La loi
Voici la plus belle surprise de ce projet.
L’équation des ondes est linéaire. Une houle sinusoïdale fait décrire à chaque particule d’eau une orbite fermée : elle monte, avance, descend, recule. Sur un cycle complet, le déplacement net est nul.
Un bouchon sur la mer oscille ; il ne dérive pas.
Or l’expérience contredit cela : à Arrábida, les vagues poussent vers la plage. Le sillage d’un bateau déplace bel et bien ce qui flotte.
La dérive de Stokes
L’explication tient à un effet du second ordre, invisible dans l’équation linéarisée. Les orbites ne sont pas tout à fait fermées : elles sont légèrement ouvertes dans le sens de propagation. Sir George Stokes l’a établi en 1847.
Pour une onde d’amplitude , de nombre d’onde , de pulsation , en eau profonde, la vitesse de dérive à la surface vaut :
Le point crucial : elle est proportionnelle au carré de l’amplitude. Un terme quadratique ne peut apparaître dans une équation linéaire.
Dans le jeu
La simulation de hauteur ne contient aucun champ de vitesse : elle ne peut pas produire la dérive de Stokes. J’ai d’abord tenté de rendre la vague asymétrique — crête pointue, creux plat, comme une vague de rivage. Sans effet : le schéma linéaire re-symétrise le train en le propageant.
Il a donc fallu ajouter le terme explicitement :
const amp = Math.abs(buffer1[idx]);
return { ax: wake.dirX * STOKES * amp, ay: wake.dirY * STOKES * amp };
⚠️ Approximation : la poussée est prise proportionnelle à et non à . Le comportement qualitatif est préservé — la balle n’est transportée que là où l’onde est passée — et la synchronisation avec le front visible est automatique.
6. Réfraction et réflexion : deux lois distinctes
Un point souvent confondu, et qui a exigé deux traitements séparés dans le moteur de rendu.
La réfraction (voir à travers l’eau)
Le fond du bassin est vu à travers la surface inclinée. La loi de Snell–Descartes :
Pour de petites inclinaisons, la déviation apparente du fond vaut environ :
La réflexion (voir sur l’eau)
Le reflet de la lune obéit à la loi de la réflexion. Si le miroir s’incline de , le rayon réfléchi tourne de :
Le rapport
Un reflet se déforme huit fois plus qu’une réfraction. C’est pourquoi, sur un lac, les reflets se brisent en éclats alors que le fond ondule doucement.
Le shader applique donc deux décalages distincts : uRefr pour le fond, et uRefr × moonWarp (3,2) pour le reflet lunaire — valeur atténuée par rapport au facteur 8 théorique, pour des raisons esthétiques.
7. Acoustique : corde ou cloche ?
La musique d’introduction a livré une leçon.
Une corde vibrante
Les modes propres d’une corde tendue de longueur sont harmoniques : leurs fréquences sont les multiples entiers de la fondamentale.
La raideur en flexion introduit une correction du second ordre, l’inharmonicité :
avec pour du nylon. Au cinquième partiel, l’écart n’est que de 0,1 % — inaudible, mais c’est lui qui distingue une corde réelle d’un synthétiseur.
Une plaque, une cloche
Une plaque vibrante obéit à l’équation biharmonique (), et non à l’équation d’onde. Ses modes ne sont pas harmoniques. Les rapports typiques d’une cloche : — — — — — .
L’erreur commise
La première version du timbre utilisait les partiels , commentés « inharmonicité des cordes ».
🐛 Ce sont des rapports de cloche. La cinquième composante (rapport 5,4) tombait à 1188 Hz là où une corde donne 1101 Hz : 1,33 demi-ton d’écart, hors de tout accord. Le testeur — musicien — l’a immédiatement entendu : « Tu as mis des sons de cloches pour un fado ? »
Les partiels sont désormais entiers, corrigés de l’inharmonicité réelle du nylon.
8. Récapitulatif
| Phénomène | Loi | Statut | ||
|---|---|---|---|---|
| Propagation des vagues | ✅ exact (schéma leapfrog, ) | |||
| Stabilité numérique | CFL : | ✅ à la limite exacte | ||
| Amortissement | ⚠️ uniforme, non visqueux | |||
| Force sur les balles | 🐛 masse en trop (latent) | |||
| Collision balle–balle | impulsion, masse réduite | ✅ exact, | ||
| Réflexion sur rocher | Neumann : | ✅ exact | ||
| Transport par la houle | dérive de Stokes | ⚠️ ajouté, linéarisé en $ | h | $ |
| Réfraction du fond | Snell–Descartes | ✅ approximation petit angle | ||
| Réflexion du ciel | ✅ rapport démontré | |||
| Timbre de corde | ✅ après correction | |||
| Frottement de la balle | ⚠️ traînée linéaire, non quadratique |
Pour aller plus loin
Trois questions ouvertes, à la portée d’une L3 :
- Le frottement. Le jeu utilise , une traînée linéaire (régime de Stokes, ). Une balle de 9 px dans l’eau est en réalité en régime turbulent : . Quelle différence de comportement observerait-on ?
- La dispersion. L’équation des ondes du jeu est non dispersive : toutes les longueurs d’onde voyagent à la même célérité . Les vraies vagues de gravité obéissent à : les grandes longueurs d’onde vont plus vite. Comment le simuler ? Que verrait-on ?
- Le déferlement. Notre équation linéaire ne peut pas produire de vague qui déferle — il y faut la non-linéarité des équations de Saint-Venant. Où intervient-elle ?
Onda Clara — jeu libre et gratuit.
La physique d’Onda Clara — illustrations exécutables (Python)
"""
La physique d'Onda Clara — illustrations exécutables.
Chaque fonction reproduit une loi réellement implémentée dans le jeu, et la vérifie numériquement.
python3 physique_onda_clara.py
Aucune dépendance hors bibliothèque standard (numpy optionnel pour la §1).
Anthony Henriques — Antweb.fr
"""
import math
# ═══════════════════════════════════════════════════════════════════
# §1 L'ÉQUATION DES ONDES ET LA CONDITION CFL
# ═══════════════════════════════════════════════════════════════════
def onde_2d(h_prev, h_curr, courant2=0.5, damping=0.982, masque=None):
"""Un pas de l'équation des ondes 2D, schéma leapfrog.
∂²h/∂t² = c² ∇²h
discrétisée en :
h[n+1] = 2h[n] - h[n-1] + C² (Σ voisins - 4h[n])
Avec C² = 1/2, les termes en h[n] s'annulent et il reste
exactement le schéma du jeu :
h[n+1] = ½ Σ voisins - h[n-1]
`masque[j][i] == 1` marque un obstacle : on y impose la condition
de Neumann ∂h/∂n = 0 (réflexion totale) en substituant la valeur
de la cellule d'eau elle-même.
Retourne (h_curr, h_next) — les tampons échangés, comme dans le jeu.
"""
ny, nx = len(h_curr), len(h_curr[0])
h_next = [[0.0] * nx for _ in range(ny)]
for j in range(1, ny - 1):
for i in range(1, nx - 1):
if masque and masque[j][i]:
continue # l'obstacle ne propage pas
c = h_curr[j][i]
def voisin(jj, ii):
# Neumann : une paroi renvoie la valeur de l'eau qui la touche,
# ce qui annule le gradient normal. Imposer 0 (Dirichlet)
# ferait de la paroi un absorbeur, pas un réflecteur.
if masque and masque[jj][ii]:
return c
return h_curr[jj][ii]
somme = (voisin(j, i - 1) + voisin(j, i + 1)
+ voisin(j - 1, i) + voisin(j + 1, i))
# Forme générale ; avec courant2 = 0.5 elle se réduit au schéma du jeu.
h_next[j][i] = (2 * c - h_prev[j][i]
+ courant2 * (somme - 4 * c)) * damping
return h_curr, h_next
def cfl_max(dimension=2):
"""Condition de Courant–Friedrichs–Lewy : C ≤ 1/√d.
L'information numérique ne doit pas voyager moins vite que l'onde
physique. Au-delà, l'amplitude croît exponentiellement.
"""
return 1.0 / math.sqrt(dimension)
def _demo_ondes():
print("§1 ÉQUATION DES ONDES")
print("─" * 62)
C2 = 0.5
C = math.sqrt(C2)
limite = cfl_max(2)
print(f" nombre de Courant du jeu : C = √{C2} = {C:.6f}")
print(f" limite CFL en 2D : 1/√2 = {limite:.6f}")
print(f" → le jeu tourne {'EXACTEMENT à la limite' if abs(C - limite) < 1e-12 else 'en deçà'}")
print()
# Stabilité : au-dessus de la limite, ça diverge.
for C2_test in (0.5, 0.6):
N = 21
prev = [[0.0] * N for _ in range(N)]
curr = [[0.0] * N for _ in range(N)]
curr[N // 2][N // 2] = 100.0
for _ in range(120):
prev, curr = onde_2d(prev, curr, courant2=C2_test, damping=1.0)
pic = max(abs(v) for ligne in curr for v in ligne)
etat = "stable" if pic < 1e3 else "DIVERGE"
print(f" C² = {C2_test} (C = {math.sqrt(C2_test):.4f})"
f" → amplitude après 120 pas : {pic:>12.3e} {etat}")
print()
# Demi-vie de l'amortissement
gamma = 0.982
t_demi = math.log(0.5) / (2 * math.log(gamma))
print(f" amortissement γ = {gamma} → demi-vie de l'énergie : {t_demi:.1f} pas")
print()
# ═══════════════════════════════════════════════════════════════════
# §2 LA FORCE SUR LES BALLES — ET LE BUG DE LA MASSE
# ═══════════════════════════════════════════════════════════════════
def acceleration_correcte(grad_h_x, grad_h_y, g=0.005):
"""a = -g ∇h
La composante tangentielle du poids sur une pente vaut F = -m g ∇h,
donc a = F/m = -g ∇h : LA MASSE DISPARAÎT.
C'est le principe d'équivalence. Tous les corps tombent pareil.
"""
return (-g * grad_h_x, -g * grad_h_y)
def acceleration_du_jeu(grad_h_x, grad_h_y, masse, g=0.005):
"""Ce que fait réellement le code :
p.vx -= grad_x * p.mass * g
soit a ∝ m. Physiquement faux — mais invisible tant que toutes
les balles partagent la même masse (0.8 dans le jeu).
"""
return (-g * grad_h_x * masse, -g * grad_h_y * masse)
def gradient_centre(h_gauche, h_droite, dx=1.0):
"""Différence finie centrée, précise au second ordre :
∂h/∂x ≈ (h[i+1] - h[i-1]) / (2 Δx)
Le jeu absorbe le facteur 1/(2Δx) dans la constante `waveForceGain`.
"""
return (h_droite - h_gauche) / (2 * dx)
def _demo_masse():
print("§2 FORCE SUR LES BALLES — un bug latent")
print("─" * 62)
grad = gradient_centre(h_gauche=-10.0, h_droite=+10.0)
print(f" gradient local ∂h/∂x = {grad:+.2f}")
print()
print(" masse | a (physique) | a (code du jeu) | rapport")
print(" ──────┼────────────────┼────────────────────┼────────")
for m in (0.4, 0.8, 1.6):
ax_vrai, _ = acceleration_correcte(grad, 0.0)
ax_jeu, _ = acceleration_du_jeu(grad, 0.0, masse=m)
print(f" {m:>4.1f} | {ax_vrai:>+9.5f} | {ax_jeu:>+9.5f} | ×{ax_jeu/ax_vrai:.2f}")
print()
print(" → L'accélération physique ne dépend PAS de la masse.")
print(" Le code la fait croître proportionnellement.")
print(" Invisible car toutes les balles ont m = 0.8.")
print(" Le bug se réveillerait avec des balles lourdes et légères.")
print()
# ═══════════════════════════════════════════════════════════════════
# §3 COLLISION ÉLASTIQUE ENTRE DEUX DISQUES
# ═══════════════════════════════════════════════════════════════════
def collision_disques(p1, p2, restitution=0.9):
"""Collision élastique 2D — exactement la formule du jeu.
p = {'x','y','vx','vy','m','r'}
Impulsion scalaire, avec μ la masse réduite :
j = -(1+e) v_n / (1/m₁ + 1/m₂)
puis v₁ -= (j/m₁) n̂ et v₂ += (j/m₂) n̂
La quantité de mouvement est conservée EXACTEMENT.
L'énergie cinétique décroît d'un facteur (1-e²) sur la normale.
"""
dx, dy = p2['x'] - p1['x'], p2['y'] - p1['y']
dist = math.hypot(dx, dy)
d_min = p1['r'] + p2['r']
if dist >= d_min or dist == 0:
return False
nx, ny = dx / dist, dy / dist
# (a) Projection de contrainte : on désimbrique. Ce n'est PAS de la
# dynamique — c'est la correction d'une erreur d'intégration.
chevauchement = d_min - dist
p1['x'] -= nx * chevauchement / 2
p1['y'] -= ny * chevauchement / 2
p2['x'] += nx * chevauchement / 2
p2['y'] += ny * chevauchement / 2
# (b) Vitesse relative projetée sur la normale
v_n = (p2['vx'] - p1['vx']) * nx + (p2['vy'] - p1['vy']) * ny
# Elles s'éloignent déjà : ne pas ajouter d'énergie.
if v_n >= 0:
return True
j = -(1 + restitution) * v_n / (1 / p1['m'] + 1 / p2['m'])
p1['vx'] -= j * nx / p1['m']
p1['vy'] -= j * ny / p1['m']
p2['vx'] += j * nx / p2['m']
p2['vy'] += j * ny / p2['m']
return True
def _demo_collision():
print("§3 COLLISION ÉLASTIQUE")
print("─" * 62)
p1 = {'x': 0.0, 'y': 0.0, 'vx': 3.0, 'vy': 0.0, 'm': 0.8, 'r': 9.0}
p2 = {'x': 17.0, 'y': 0.0, 'vx': -1.0, 'vy': 0.0, 'm': 1.6, 'r': 9.0}
def qte_mvt(a, b):
return (a['m'] * a['vx'] + b['m'] * b['vx'],
a['m'] * a['vy'] + b['m'] * b['vy'])
def energie(a, b):
return (0.5 * a['m'] * (a['vx']**2 + a['vy']**2)
+ 0.5 * b['m'] * (b['vx']**2 + b['vy']**2))
p_avant, e_avant = qte_mvt(p1, p2), energie(p1, p2)
collision_disques(p1, p2, restitution=0.9)
p_apres, e_apres = qte_mvt(p1, p2), energie(p1, p2)
print(f" quantité de mouvement avant : {p_avant[0]:+.6f}")
print(f" après : {p_apres[0]:+.6f}")
err = abs(p_apres[0] - p_avant[0])
print(f" écart : {err:.2e} "
f"{'CONSERVÉE' if err < 1e-12 else 'VIOLÉE'}")
print()
print(f" énergie cinétique avant : {e_avant:.6f}")
print(f" après : {e_apres:.6f}")
print(f" perdue : {(1 - e_apres/e_avant)*100:.2f} %"
f" (attendu ≈ 1-e² = {(1-0.9**2)*100:.0f} % sur la normale)")
print()
# e = 1 : collision parfaitement élastique, énergie conservée
q1 = {'x': 0.0, 'y': 0.0, 'vx': 3.0, 'vy': 0.0, 'm': 0.8, 'r': 9.0}
q2 = {'x': 17.0, 'y': 0.0, 'vx': -1.0, 'vy': 0.0, 'm': 1.6, 'r': 9.0}
e0 = energie(q1, q2)
collision_disques(q1, q2, restitution=1.0)
e1 = energie(q1, q2)
print(f" avec e = 1.0 : énergie {e0:.6f} → {e1:.6f} "
f"(écart {abs(e1-e0):.2e}) → CONSERVÉE")
print()
# ═══════════════════════════════════════════════════════════════════
# §4 RÉFLEXION : NEUMANN CONTRE DIRICHLET
# ═══════════════════════════════════════════════════════════════════
def _demo_neumann():
print("§4 RÉFLEXION SUR UN OBSTACLE")
print("─" * 62)
print(" Neumann ∂h/∂n = 0 → réflexion totale, en phase")
print(" Dirichlet h = 0 → la paroi impose zéro : elle ABSORBE")
print()
N, PAROI = 61, 53
def simule(neumann, pas):
prev = [[0.0] * N for _ in range(N)]
curr = [[0.0] * N for _ in range(N)]
masque = [[0] * N for _ in range(N)]
for j in range(N):
masque[j][PAROI] = 1
curr[N // 2][10] = 100.0 # une impulsion, loin de la paroi
for _ in range(pas):
nxt = [[0.0] * N for _ in range(N)]
for j in range(1, N - 1):
for i in range(1, N - 1):
if masque[j][i]:
continue
c = curr[j][i]
def v(jj, ii):
if masque[jj][ii]:
# Neumann : la paroi renvoie la valeur de l'eau (gradient nul)
# Dirichlet : la paroi impose zéro
return c if neumann else 0.0
return curr[jj][ii]
s = v(j, i-1) + v(j, i+1) + v(j-1, i) + v(j+1, i)
nxt[j][i] = 0.5 * s - prev[j][i]
prev, curr = curr, nxt
return curr
# Le gradient normal à la paroi est CE QUI DÉFINIT la condition.
print(" pas | condition | h(eau) | h(paroi) | ∂h/∂n")
print(" ────┼────────────┼─────────┼──────────┼─────────")
j = N // 2
for pas in (60, 80):
for neumann, nom in ((True, "Neumann "), (False, "Dirichlet")):
h = simule(neumann, pas)
h_eau = h[j][PAROI - 1]
h_paroi = h_eau if neumann else 0.0 # valeur VUE par la cellule d'eau
grad = h_paroi - h_eau
print(f" {pas} | {nom} | {h_eau:>+7.4f} | {h_paroi:>+8.4f} | {grad:>+7.4f}")
print()
print(" → Neumann annule EXACTEMENT le gradient normal : l'onde rebondit.")
print(" Dirichlet le maximise : la paroi pompe l'onde au lieu de la renvoyer.")
print()
print(" Le piège rencontré dans le jeu : écrire h = 0 DANS le rocher revient")
print(" à imposer Dirichlet. La cellule d'eau voisine moyennait alors ce zéro,")
print(" et le rocher se comportait en absorbeur. La correction consiste à faire")
print(" substituer, par la cellule D'EAU, sa propre valeur à celle du rocher.")
print()
# ═══════════════════════════════════════════════════════════════════
# §5 LA DÉRIVE DE STOKES
# ═══════════════════════════════════════════════════════════════════
def derive_stokes(amplitude, nombre_onde, pulsation):
"""Vitesse de dérive de Stokes en surface, eau profonde :
ū_S = a² k ω
Elle est QUADRATIQUE en amplitude. C'est un effet du second ordre,
absent de l'équation d'onde linéarisée.
"""
return amplitude**2 * nombre_onde * pulsation
def _demo_stokes():
print("§5 LA DÉRIVE DE STOKES")
print("─" * 62)
print(" Une houle linéaire fait décrire des orbites FERMÉES :")
print(" déplacement net d'un bouchon sur un cycle = 0.")
print()
# Orbite d'une particule sous une houle linéaire : intégrale sur un cycle
a, k, omega = 0.5, 1.0, 1.0
dt, dx_net = 0.001, 0.0
t = 0.0
while t < 2 * math.pi / omega:
# vitesse horizontale d'une particule, ordre 1 : u = a ω cos(kx - ωt)
dx_net += a * omega * math.cos(-omega * t) * dt
t += dt
print(f" ordre 1, déplacement net sur un cycle : {dx_net:+.6f}")
print(f" (le résidu est une erreur d'intégration : dt = {dt}. Il tend vers 0.)")
print()
print(" Mais l'orbite réelle est légèrement OUVERTE (ordre 2, Stokes 1847) :")
print()
print(" amplitude | ū_S = a²kω | rapport au précédent")
print(" ──────────┼──────────────┼─────────────────────")
prec = None
for amp in (0.25, 0.5, 1.0):
u = derive_stokes(amp, k, omega)
r = f"×{u/prec:.1f}" if prec else "—"
print(f" {amp:>4.2f} | {u:>8.4f} | {r}")
prec = u
print()
print(" → doubler l'amplitude QUADRUPLE la dérive.")
print(" Un terme quadratique ne peut pas naître d'une équation linéaire :")
print(" le jeu doit l'ajouter explicitement.")
print()
# ═══════════════════════════════════════════════════════════════════
# §6 RÉFRACTION CONTRE RÉFLEXION
# ═══════════════════════════════════════════════════════════════════
def deviation_refraction(theta, n=1.33):
"""Déviation apparente du fond vu à travers une surface inclinée de θ.
Petit angle, loi de Snell : δ ≈ (1 - 1/n) θ
"""
return (1 - 1 / n) * theta
def deviation_reflexion(theta):
"""Un miroir incliné de θ fait tourner le rayon réfléchi de 2θ."""
return 2 * theta
def _demo_optique():
print("§6 RÉFRACTION CONTRE RÉFLEXION")
print("─" * 62)
print(" pente θ | réfraction (fond) | réflexion (ciel) | rapport")
print(" ─────────┼───────────────────┼──────────────────┼────────")
for deg in (1, 3, 5, 10):
th = math.radians(deg)
r1, r2 = deviation_refraction(th), deviation_reflexion(th)
print(f" {deg:>2}° | {math.degrees(r1):>5.2f}° |"
f" {math.degrees(r2):>5.2f}° | ×{r2/r1:.1f}")
print()
print(" → Le reflet se déforme 8× plus que la réfraction.")
print(" C'est pourquoi, sur un lac, les reflets se brisent")
print(" alors que le fond ondule doucement.")
print()
# ═══════════════════════════════════════════════════════════════════
# §7 CORDE OU CLOCHE ?
# ═══════════════════════════════════════════════════════════════════
def partiels_corde(f1, n_max=6, B=8e-5):
"""Modes propres d'une corde raide : f_n = n f₁ √(1 + B n²)
B ≈ 8e-5 pour du nylon. L'écart aux multiples entiers est infime,
mais c'est lui qui empêche le son d'être synthétique.
"""
return [n * f1 * math.sqrt(1 + B * n * n) for n in range(1, n_max + 1)]
def partiels_cloche(f1):
"""Modes d'une plaque (équation biharmonique ∇⁴). NON harmoniques."""
return [f1 * r for r in (1.0, 2.0, 2.4, 3.0, 4.2, 5.4)]
def _demo_acoustique():
print("§7 CORDE OU CLOCHE ?")
print("─" * 62)
f1 = 220.0
corde = partiels_corde(f1)
cloche = partiels_cloche(f1)
print(f" fondamentale : {f1} Hz")
print()
print(" n | corde (Hz) | écart à n·f₁ | cloche (Hz) | rapport")
print(" ──┼──────────────┼──────────────┼───────────────┼────────")
for n in range(6):
c, b = corde[n], cloche[n]
entier = (n + 1) * f1
ecart = abs(c - entier) / entier * 100
print(f" {n+1} | {c:>8.2f} | {ecart:>5.3f} % |"
f" {b:>8.2f} | {b/f1:.2f}")
print()
print(" → La corde reste à 0,1 % des multiples entiers.")
print(" La cloche s'en écarte de 20 % à 40 % : c'est un autre monde.")
print()
print(" La v1.18.2 du jeu synthétisait les partiels [1, 2, 3, 4.2, 5.4].")
print(" Ses 4e et 5e composantes portaient donc des rapports de cloche :")
print()
for rang, rapport in ((4, 4.2), (5, 5.4)):
faux = rapport * f1
juste = corde[rang - 1]
demi_ton = juste * (2 ** (1 / 12) - 1)
print(f" composante {rang} : synthétisée à {faux:>7.1f} Hz, "
f"une corde donnerait {juste:>7.1f} Hz")
print(f" écart {faux - juste:>+6.1f} Hz = "
f"{abs(faux-juste)/demi_ton:.2f} demi-ton")
print()
print(" → plus d'un demi-ton hors de tout accord : un musicien l'entend.")
print()
# ═══════════════════════════════════════════════════════════════════
def main():
print()
print("╔" + "═" * 60 + "╗")
print("║" + " LA PHYSIQUE D'ONDA CLARA — vérifications numériques".ljust(60) + "║")
print("╚" + "═" * 60 + "╝")
print()
_demo_ondes()
_demo_masse()
_demo_collision()
_demo_neumann()
_demo_stokes()
_demo_optique()
_demo_acoustique()
print("─" * 62)
print(" ✅ exact ⚠️ approximation assumée 🐛 erreur (masse, §2)")
print()
if __name__ == "__main__":
main()

